Skip to content

fix(auth): await refreshed credential handoff (ENG-2116) - #806

Merged
lucas-koontz merged 6 commits into
mainfrom
fix/eng-2116-refresh-active-jwt-main
Sep 2, 2026
Merged

fix(auth): await refreshed credential handoff (ENG-2116)#806
lucas-koontz merged 6 commits into
mainfrom
fix/eng-2116-refresh-active-jwt-main

Conversation

@lucas-koontz

@lucas-koontz lucas-koontz commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

User story

As a signed-in Cowork user
I want an active turn to adopt refreshed MindsHub credentials and recover from one isolated authentication refusal
So that long-running work is not discarded or blamed on my account

Hotfix scope

This is #778 replayed onto main so the fix can ship without waiting for the 431-commit staging backlog. All five commits are byte-identical to the ones on fix/eng-2116-refresh-active-jwt, confirmed with git range-diff; nothing else from staging came along, and there is no extra commit here. #778 stays open against staging, and the backmerge resolves it.

Why this matters

The desktop can refresh a short-lived MindsHub JWT while the running sidecar still holds the old value. After sleep or a long turn, the next model call can therefore fail as if the user's account were disconnected even though the desktop session is healthy.

Acceptance criteria

  • A refreshed JWT is saved and handed to the running sidecar before refresh reports success.
  • A failed handoff retries after 10 seconds without rotating the Keycloak token again.
  • Resume refresh retries transient Keycloak failures inside the 60-second expiry buffer and schedules the next refresh from token issuance.
  • A new response waits at most 12 seconds for refresh and handoff when the resolved required planning or coding role needs the runtime Minds credential.
  • Direct OpenAI or Anthropic turns, and router-only Minds usage, are not blocked by an unrelated Minds credential handoff.
  • Credential selection and handoff are serialized, and logout invalidates in-flight refresh, repair, and retry work.
  • A comparable post-deployment window has no auth-typed failures for the affected paid install.

How to test

  1. Refresh token A to token B and hold the sidecar PUT; verify refresh and a gated Minds response do not complete until token B lands.
  2. Refuse the first sidecar PUT, advance 10 seconds, and verify the selected credential is retried without a second Keycloak request.
  3. Fail Keycloak transiently, advance 10 seconds, and verify the session recovers while preserving stored tokens.
  4. With the resume gate active, report that a required planning or coding role uses Minds and verify response creation waits; report direct OpenAI/Anthropic roles or router-only Minds usage and verify it continues immediately.
  5. Make the requirement probe fail or hang and verify the request remains fail-closed within the shared 12-second bound.
  6. Cancel during the initial handoff, a handoff-only retry, BYOK lookup, and selected-credential repair; verify stale work cannot reopen the gate.

Notes for the reviewer

Refresh and delivery are separate outcomes. ok means the new token was both stored and accepted by the sidecar. A sidecar refusal returns handoff_pending and schedules a handoff-only retry, so Keycloak is not rotated twice.

The wake barrier is bounded by the renderer, not by the refresh. reconcileInFlight reaps a reservation the server never saw after four unseen polls, at roughly 13 seconds, and _streamResponse swallows the resulting AbortError. A hold that outlives that loses the message with the thinking placeholder still on screen, so the barrier gives up first and the request fails visibly instead.

The wake barrier is narrow. It intercepts only same-port POST /api/v1/responses[/] while a near-expiry resume refresh is active. The sidecar health flag decides whether the resolved required roles need the runtime Minds credential; an explicit false bypasses the barrier, while an unknown or failed probe remains fail-closed.

Cancellation is independent of retry sequencing. Logout advances a cancellation epoch checked after every asynchronous selection, refresh, handoff, and repair step. Ordinary retry completion does not invalidate a concurrent or newer refresh.

Verified locally, against main

Run in a worktree cut from origin/main (25db478f) with these five commits replayed on top.

Check Observed result
Full TypeScript check, all three projects Clean, zero diagnostics
npm test (vitest run) 2,949 passed across 245 files, 0 failed
npm run test:coverage, which is what CI actually runs Passed. minds-credential.ts holds its pinned 100% statements and 100% branches after a 117-line change
The replay's own five test files 105 passed
Staging-only symbol sweep over the replayed diff No findings. The four new minds-auth exports and both new modules resolve at HEAD, and the clean typecheck covers the renamed-export class of breakage this replay was most at risk for
git range-diff against #778 All five replayed commits byte-identical
Self-review round (76b401e4) Three introduced defects fixed: sign-out stranding a rotated refresh token, the server-start hook never releasing the wake barrier, and the boot hand-over warning firing on every launch. Each new test fails against its own mutant

Ships with

Deploys: mindsdb/cowork-server#444

Merge order: Anton #429 first, then cowork-server #444, then this desktop PR.
Deployment: cowork-server is the sole preview/deploy anchor.

The 25s hold outlived the renderer's stranded-reservation reap, which
aborts at ~13-20s and swallows the AbortError, losing the message with the
thinking placeholder still on screen. Bound the barrier under that floor so
a slow wake fails visibly instead of silently; the retry loop underneath
still converges.

Release the barrier on syncUsableMindsCredential rather than on the PUT
landing alone: sleep during the loopback call leaves the abort timer on a
clock that does not advance, so a push can complete on wake carrying a JWT
that expired hours earlier.

Reset the gate on sign-out instead of latching it shut, drain a pre-sleep
exchange the way refreshAfterOrgSwitch does, back off both retry loops, and
gate /responses/answer, which also puts LLM work on the wire.
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

PR environment is up · pr-cowork-806

Console https://pr-cowork-806.dev.mindshub.ai
Auth API https://auth-pr-cowork-806.dev.mindshub.ai
Cowork Web https://cowork-pr-cowork-806.dev.mindshub.ai
Keycloak Admin https://auth-pr-cowork-806.dev.mindshub.ai/auth/admin/
Inference API https://api-pr-cowork-806.dev.mindshub.ai/v1
Cowork Server API https://cowork-pr-cowork-806.dev.mindshub.ai/api
Namespace pr-cowork-806

Every service is in this namespace. The ones this PR does not build run the staging image unless you link them with Deploys: in the PR body.

Updated on every push to this PR.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It changes core authentication/refresh and request-interception behavior in the Electron main process, which is high-impact and warrants final human review despite strong automated coverage.

Pull request overview

Implements a hotfix to ensure refreshed MindsHub JWTs are not considered “successful” until they are handed off to the running sidecar, and adds a bounded “resume gate” that can temporarily hold response-creation requests after wake so long-running turns don’t run on an expired credential.

Changes:

  • Adds a resume credential gate with a strict 12s ceiling and explicit reset semantics for logout.
  • Serializes credential selection + sidecar handoff, and makes token refresh await “usable + landed” credential delivery (with handoff-only retry on refusal).
  • Introduces a narrow request gate in the Electron loopback interceptor to defer only response-creation POSTs when the sidecar reports the runtime credential is required.
File summaries
File Description
src/main/minds-resume-gate.ts New bounded gate primitive for post-resume credential readiness.
src/main/minds-resume-gate.test.ts Unit tests covering gate lifecycle, timeout behavior, and reset/unblock semantics.
src/main/minds-response-request-gate.ts New logic to identify and gate response-creation loopback requests during resume refresh.
src/main/minds-response-request-gate.test.ts Tests for request classification, fail-closed health probing, and timing bounds.
src/main/minds-credential.ts Serializes sidecar handoffs; adds “usable” selection semantics and exposes BYOK detection.
src/main/minds-credential.test.ts Tests for serialization ordering, usable/landed quadrants, and gate interactions.
src/main/minds-auth.ts Refresh now awaits sidecar acceptance; adds handoff-only retry, backoff strategy, and logout fencing.
src/main/minds-auth.refresh.test.ts Extensive refresh/resume/logout/timeout regression tests for the new contract.
src/main/minds-auth.orgs.test.ts Updates org-selection tests to include sidecar routes under the new handoff behavior.
src/main/index.ts Wires resume refresh on OS resume; adds loopback request gating; strengthens logout fencing/reset.
Review details
  • Files reviewed: 10/10 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@alecantu7 alecantu7 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — de8be78b (hotfix to main)

I reviewed and approved the staging counterpart (#778 @ 6d789c04), including the wake barrier and the sign-out fence. This carries that content unchanged.

Verified identical to the approved version

Diffing the ENG-2116 files between 6d789c04 and this head — minds-auth.ts, minds-credential.ts, minds-resume-gate.ts, minds-response-request-gate.ts, index.ts — returns empty. Byte-identical. So everything I verified there still holds without re-deriving it: the usable && landed conjunct and its quadrant test, the wake barrier with no constructible wedge, the expiry path cancelling rather than sending a stale JWT, and the sign-out fence's try/finally depth counter plus the unconditional resetMindsResumeCredentialGate().

The larger diff against the approved head is main-vs-staging drift (the ENG-2044 artifact-download surface and related work that exists on staging and not on main), not content this PR introduces.

Carried forward, unchanged

The one item I could not settle on #778 applies here identically, and now against production: the 12s barrier is justified as clearing a ~13s reap, but reservationReleaseDecision uses unseenThreshold = 4 with minMissSpacingMs = 4000 and the tally is driven by the 5s interval and an off-cycle onFocus handler — so focus polls at the 4s floor give 0/4/8/12, exactly equal to the bound. Not blocking and not a claim; the arithmetic is solid and the reachability is what I could not close. Also unchanged: the commit rewrote the old absolute assertions into self-referential MINDS_RESUME_READY_TIMEOUT_MS - 3_000 forms, so reverting the constant to 25_000 leaves the suite green.

Merge position

This is last. Against a sidecar without cowork-server#444, minds_runtime_credential_required is absent → null → the gate goes conservative. That is the correct fail-safe, but its cost lands on users rather than CI, so the order matters more here than the green ticks suggest.

Two things that apply to the whole hotfix set

Six open PRs now exist for ENG-2116 — anton#421 / cowork-server#428 / cowork#778 against staging (all three approved), and anton#429 / cowork-server#444 / this set against main. Both tracks carry the same change. Whichever ships, the other wants closing or rebasing deliberately, and per the repo convention mainstaging gets merged back by hand — nothing does it automatically, so staging will otherwise sit behind production carrying an approved duplicate of what already shipped.

main is production. These merge straight to it, so the usual staging soak does not happen. That raises the cost of the pin step below from "housekeeping" to "the thing that breaks the build".


Verdict: COMMENT. No new findings. Not an approval.

Three defects from self-review, all in behavior this branch introduces.

Sign-out bumps the cancellation epoch before getRevokeToken joins an
in-flight refresh, so that exchange returned superseded ahead of
saveTokens. Keycloak had already rotated the refresh token and invalidated
the one we sent, so endKeycloakSession posted a token the IdP rejects: the
SSO session survived sign-out and the next sign-in silently reused the same
account with no picker. The window is structural, not rare, because the
scheduled refresh fires exactly while getRevokeToken's cache guard fails.
Persist ahead of the fence; only the write escapes it.

The server-start hook pushed the credential without settling the resume
barrier or cancelling the retry ladder, alone among the landing paths. A
sidecar restarting during a post-wake retry came back holding a usable
credential while turns were still held their full bound and cancelled.

Boot refreshes tokens before it starts a sidecar, so the hand-over warning
and its retry timer fired on every launch of a signed-in install and buried
the real failures the log exists to surface. Only a sidecar that exists can
refuse, so ask minds-credential whether one is reachable.

Tests cover all three plus the two resume-gate guards nothing pinned; each
fails against its own mutant. The README now documents the wake barrier,
which is user-visible and was described nowhere.

Refs: ENG-2116

@alecantu7 alecantu7 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving

Re-verified against this head — the branch moved after my last review, and 76b401e4 is not a trivial delta, so I reviewed it rather than diffing it.

The new commit fixes two real bugs

A stranded rotated refresh token. The previous fence returned before saveTokens when sign-out cancelled mid-exchange — but Keycloak had already rotated and invalidated the token we sent, so endKeycloakSession was handed one the IdP rejects: the SSO session survived sign-out and the next sign-in silently reused the same account with no picker. Moving the persist ahead of the fence is right, and the comment explains why only the write escapes.

A barrier left armed by a restarted sidecar. The server-start hook pushed the credential but never settled the resume gate or cancelled the retry ladder, so a sidecar restarting during a post-wake retry came back holding a usable credential while /api/v1/responses kept being held its full bound and cancelled. handOffMindsCredentialToStartedSidecar closes that.

What I went after and could not make stick

Moving saveTokens ahead of the cancellation fence looked like it should let a late response resurrect tokens after clearTokens()getRevokeToken is a 5s Promise.race, so the refresh can outlive it, and saveTokens has no guard of its own and calls broadcastAuthChanged(true). The index.ts comment claiming the post-lookup fence prevents a late write is, strictly, no longer the mechanism that does it.

But the behaviour is still safe: doRefreshTokens checks getTokenStoreVersion() !== tokenStoreVersion before saveTokens, and clearTokens() bumps that version — so a late response after sign-out returns superseded and never writes. Only a rotation where the store is otherwise untouched escapes, which is exactly the stated intent. Recording the reasoning because the index.ts comment now points at the wrong guard, and the next reader will check it.

Everything from the earlier rounds still stands: the usable && landed quadrant, the wake barrier with no constructible wedge, the expiry path cancelling rather than sending a stale JWT, and the sign-out fence's try/finally depth counter. Main project 975 passed / 66 files at this head.

Carried forward, unchanged

The 12s barrier arithmetic I could not close on #778 applies identically here, now against production: unseenThreshold = 4 with minMissSpacingMs = 4000, and focus polls at the 4s floor give 0/4/8/12 — exactly the bound. Not blocking, not a claim. Same for the self-referential timeout assertions: reverting the constant to 25_000 leaves the suite green.

The pin sequence is a precondition on merge, not on this approval. cowork-server#444 pins anton to a rev on an unmerged branch, and its own comment says so. Order: merge anton#429 → re-pin #444 to the resulting anton main commit (or branch = "main") → regenerate the lockfile → merge #444 → merge cowork#806. The image installs via uv sync --frozen, so a rev on an unmerged branch reaching a cowork-server main build is a broken image, not a stale one.\n\nBoth tracks are still open. anton#421 / cowork-server#428 / cowork#778 target staging and are also approved. Whichever ships, the other wants closing or rebasing deliberately — mainstaging is a manual merge here.

Approved on Alejandro's call. Merging is his, and this one is last.

@lucas-koontz
lucas-koontz added this pull request to the merge queue Sep 2, 2026
Merged via the queue into main with commit 1933f22 Sep 2, 2026
31 of 32 checks passed
@lucas-koontz
lucas-koontz deleted the fix/eng-2116-refresh-active-jwt-main branch September 2, 2026 19:28
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 2, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants