Skip to content

fix(claude-auth): recover stale managed accounts via cross-store token reconcile - #6864

Closed
Jinwoo-H wants to merge 1 commit into
mainfrom
Jinwoo-H/fix-claude-managed-auth-cross-store-reconcile
Closed

Jinwoo-H wants to merge 1 commit into
mainfrom
Jinwoo-H/fix-claude-managed-auth-cross-store-reconcile

Conversation

@Jinwoo-H

@Jinwoo-H Jinwoo-H commented Jun 30, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes the root cause of #6234: Orca's managed Claude account requires re-authentication ~daily/overnight, while the user's terminal Claude Code (~/.claude) stays logged in. PR #6828 added a one-click "Re-authenticate" button — a good stopgap — but this addresses why re-auth is needed at all.

Root cause (verified)

Claude Code OAuth uses single-use, rotating refresh tokens (corroborated by anthropics/claude-code issues #61923, #54443, #64232; matches OAuth 2.1 rotation). A host managed account is materialized into the shared ~/.claude — deliberately, with no per-account CLAUDE_CONFIG_DIR, so session history/projects stay shared (#5318). But Orca also keeps the account's credentials in a separate managed store. Both copies share one refresh-token lineage at the backend.

When a terminal Claude session refreshes, it rotates the shared refresh token and invalidates the copy in Orca's managed store. The next managed usage fetch fails with invalid_grant, and the existing CLI-spawn fallback can't help because it reuses the same dead credentials — only an interactive re-auth recovers. The ~10 prior fixes (#1286→#5995) only ever reconciled Orca's own materialized runtime file, never a terminal-owned rotation while Orca was idle. (Codex avoids this via an ~8-day refresh cadence and explicit cross-instance disk reconciliation — it rotates too.)

The fix

A cross-store reconcile-then-retry, mirroring how Codex survives ("some other instance already refreshed it → reload from disk"):

On a recoverable-auth usage failure (401 / non-scope 403), Orca re-runs syncForCurrentSelection — whose existing read-back adopts the terminal's rotated, same-identity token back into the managed store — then re-reads credentials and retries the OAuth fetch, before any claude spawn. Recovers without an interactive re-auth.

  • runtime-auth-service.ts — new reconcileActiveManagedAuthFromRuntime(), a thin wrapper over the serialized sync. The live-PTY guard inside the sync is preserved, so it never races a live session's own single-use rotation.
  • claude-fetcher.ts — reconcile runs gated on shouldAttemptDelegatedRefresh and after the live-PTY defer check, but independent of allowCliFallback — so it works on Windows managed accounts (where PTY/CLI fallback is disabled), since reconcile needs no spawn, only file/network token adoption.
  • rate-limits/service.ts — reconcile callback scoped to managed accounts only; system-default auth is owned by the user's terminal and must not be re-synced/refreshed by Orca.
  • WSL managed accounts are isolated by their own Linux CLAUDE_CONFIG_DIR, so the shared-store rotation war doesn't apply — reconcile is a safe no-op there.

Testing

  • pnpm typecheck — clean
  • pnpm lint — clean (1 pre-existing warning in an untouched file)
  • pnpm build — clean
  • pnpm vitest run src/main/rate-limits/ src/main/claude-accounts/ — 304 passed

New tests:

  • Terminal-rotated token adopted into the managed store on real temp files (runtime-auth-service.test.ts).
  • Reconcile recovers with allowPtyFallback: false (Windows regression — this test fails without the gating fix).
  • Reconcile + retry succeeds without spawning the CLI; falls through to CLI repair when no fresher token; reconcile failure is non-fatal (claude-fetcher.test.ts).
  • Resolver wiring routes managed → reconcile, system-default → no callback (service.test.ts).

Verification honesty

This was verified through layered tests exercising every real seam except the live Anthropic endpoint — a true overnight repro needs a real subscription + a real terminal rotation + waiting for token expiry, which isn't reproducible in CI/dev. The change was also run through an adversarial review pass, which caught a real defect (reconcile being dead code on Windows because it was nested under the allowCliFallback gate); that's fixed and covered by the Windows regression test above.

AI Review Report

  • Cross-platform: no new platform branches; reconcile delegates to syncForCurrentSelection, already tested on darwin (Keychain) and linux/win32 (file store). Now reachable on Windows managed accounts.
  • Remote/SSH/WSL: the normalized fetch target is threaded through the reconcile resolver, so the correct runtime reconciles. WSL is isolated → safe no-op.
  • Provider neutrality: Claude-only; Codex/others untouched.
  • Live-session safety: reconcile runs only after the live-PTY defer early-return, and the sync's own live-PTY gate skips refresh while a claude PTY owns the creds — no double-rotation.
  • Performance: one extra file-read + at most one extra OAuth retry, only on a recoverable-auth failure; no new polling.
  • Security: no new IPC; no secrets logged (diagnostics widened to nullable creds, only console.warn).

Relates to #6234. Complements #6828.

Made with Orca 🐋

…n reconcile

Orca's managed Claude account requires re-authentication ~daily/overnight
while the user's terminal Claude Code (~/.claude) stays logged in (#6234).

Root cause: Claude OAuth uses single-use *rotating* refresh tokens, and a
host managed account is materialized into the *shared* ~/.claude (no
per-account CLAUDE_CONFIG_DIR, so session history stays shared). When a
terminal Claude session refreshes, it rotates the shared refresh token and
invalidates the copy in Orca's separate managed store. The next usage fetch
then fails with invalid_grant, and the existing CLI fallback can't help
because it reuses the same dead credentials — only an interactive re-auth
recovers. Prior fixes only ever reconciled Orca's own materialized runtime
file, never a terminal-owned rotation while Orca was idle.

Fix: a cross-store reconcile-then-retry, mirroring how Codex survives
("some other instance already refreshed it -> reload from disk"). On a
recoverable-auth usage failure (401 / non-scope 403), Orca re-runs
syncForCurrentSelection — whose existing read-back adopts the terminal's
rotated, same-identity token back into the managed store — then re-reads
credentials and retries the OAuth fetch before any CLI spawn. Recovers
without an interactive re-auth.

- runtime-auth-service: new reconcileActiveManagedAuthFromRuntime(), a thin
  wrapper over the serialized sync; the live-PTY guard inside the sync is
  preserved, so it never races a live session's own single-use rotation.
- claude-fetcher: reconcile runs gated on shouldAttemptDelegatedRefresh and
  AFTER the live-PTY defer check, but independent of allowCliFallback — so it
  works on Windows managed accounts (where PTY/CLI fallback is disabled) since
  reconcile needs no spawn, just file/network token adoption.
- rate-limits service: reconcile callback scoped to managed accounts only;
  system-default auth is owned by the user's terminal and must not be re-synced.
- WSL managed accounts are isolated by their own Linux CLAUDE_CONFIG_DIR, so
  the shared-store rotation war does not apply; reconcile is a safe no-op there.

Tests: terminal-rotated token adopted into the managed store on real temp
files; reconcile recovers with allowPtyFallback:false (Windows regression);
falls through to CLI repair when no fresher token; reconcile failure is
non-fatal; resolver wiring routes managed->reconcile and system-default->none.
@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

A new reconcileActiveManagedAuthFromRuntime method is added to ClaudeRuntimeAuthService. It synchronizes runtime credential state for a target account and returns the resulting auth preparation. A new attemptReconcileThenRetryOAuth helper is added to claude-fetcher.ts; it runs the reconcile callback, re-reads OAuth credentials, and retries the OAuth usage fetch before falling back to CLI/PTY repair. FetchClaudeRateLimitsOptions gains an optional reconcileManagedAuth callback. RateLimitService adds a ClaudeAuthReconcileResolver type, a setter, and a buildClaudeReconcileCallback helper that wires the callback into both fetch cycles. Startup code in index.ts registers the resolver. Tests cover the reconcile-then-retry success path, the allowPtyFallback: false case, fallthrough when reconciliation yields no fresher token, and fallthrough when reconciliation throws.

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: recovering stale managed Claude auth via token reconciliation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🧹 Nitpick comments (3)
src/main/claude-accounts/runtime-auth-service.ts (1)

128-136: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Trim the new rationale comment to one or two lines.

The explanation is useful, but this new Why: block is longer than the project’s comment guideline allows.

Suggested shorter comment
-  // Why: Claude OAuth refresh tokens are single-use and rotating, and Orca's
-  // managed store shares the same lineage as the terminal's ~/.claude. When a
-  // terminal Claude session rotates the token, the managed copy goes stale and
-  // a usage fetch fails with invalid_grant. Re-running the sync adopts the
-  // terminal's rotated token back into the managed store (the read-back path),
-  // and proactively refreshes when no live PTY owns the credentials — so a
-  // failed fetch can recover without forcing an interactive re-auth. The
-  // live-PTY guard inside the sync is preserved, so this never races a live
-  // session's own rotation.
+  // Why: terminal Claude can rotate the shared OAuth lineage and leave the
+  // managed copy stale; sync read-back adopts it without racing live PTYs.

As per coding guidelines, “Keep comments short — one or two lines, capturing only the non-obvious reason.”

Source: Coding guidelines

src/main/rate-limits/claude-fetcher.ts (1)

525-529: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Shorten the added Why: comments.

These comments capture the right rationale, but each exceeds the one/two-line guideline.

Suggested shorter wording
-// Why: re-sync runtime auth (adopting a terminal-rotated token into the managed
-// store and proactively refreshing when safe), re-read credentials, and retry
-// the OAuth usage fetch. Returns the successful result, or null to fall through
-// to the heavier CLI-spawn repair. Avoids spawning `claude` against the same
-// dead credentials when the terminal already minted a fresh token.
+// Why: reconcile can adopt an already-rotated terminal token, avoiding heavier
+// CLI repair when the managed copy is stale.
-  // Why: when a usage fetch fails with a stale managed token, a terminal Claude
-  // session may have already rotated the shared ~/.claude token. This callback
-  // re-syncs runtime auth so the rotated token is adopted into the managed
-  // store before retrying — recovering without an interactive re-auth. It
-  // preserves the live-PTY guard, so it never races a live session's rotation.
+  // Why: lets managed auth adopt a terminal-rotated token before retrying OAuth,
+  // recovering without spawning Claude.
-      // Why: try the cheap cross-store reconcile first — when a terminal session
-      // already rotated the shared token, adopting it recovers without a `claude`
-      // spawn or interactive re-auth. Runs on every platform (unlike the CLI
-      // repair below, which is gated by allowCliFallback / unreliable on Windows)
-      // because reconcile needs no PTY — just file/network token adoption.
+      // Why: reconcile is PTY-free, so try it before platform-gated CLI repair
+      // when delegated-refresh errors may be from a rotated managed token.

As per coding guidelines, “Keep comments short — one or two lines, capturing only the non-obvious reason.”

Also applies to: 614-618, 673-677

Source: Coding guidelines

src/main/rate-limits/service.ts (1)

40-42: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Condense these new rationale comments.

Both comments can preserve the non-obvious reason within the project’s one/two-line limit.

Suggested shorter wording
-// Why: re-sync runtime auth after a failed Claude usage fetch so a token a
-// terminal session already rotated is adopted into the managed store before
-// retrying — recovering a stale managed account without an interactive re-auth.
+// Why: adopt a terminal-rotated token into managed auth after a failed fetch,
+// avoiding interactive re-auth for stale managed accounts.
-  // Why: build the cross-store reconcile callback for the active managed
-  // account. Scoped to managed accounts — system-default auth is owned by the
-  // user's terminal, not Orca, so Orca must not re-sync/refresh it.
+  // Why: only Orca-managed accounts can be reconciled; system-default auth is
+  // owned by the user's terminal.

As per coding guidelines, “Keep comments short — one or two lines, capturing only the non-obvious reason.”

Also applies to: 833-835

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: ffe38ced-839f-45ce-bdad-2778b10398cf

📥 Commits

Reviewing files that changed from the base of the PR and between 9078eca and 5091fd5.

📒 Files selected for processing (7)
  • src/main/claude-accounts/runtime-auth-service.test.ts
  • src/main/claude-accounts/runtime-auth-service.ts
  • src/main/index.ts
  • src/main/rate-limits/claude-fetcher.test.ts
  • src/main/rate-limits/claude-fetcher.ts
  • src/main/rate-limits/service.test.ts
  • src/main/rate-limits/service.ts

Comment on lines +537 to +563
try {
await input.options.reconcileManagedAuth()
} catch (err) {
warnClaudeUsageFetchFailure(input.options?.authPreparation, null, err)
return null
}
const reconciledCredentials = await readOAuthCredentials(
resolveOAuthCredentialReadOptions(input.options?.authPreparation)
)
if (!reconciledCredentials.token) {
return null
}
recordAttempt(input.attempts, 'oauth')
try {
const oauthRetry = await fetchViaOAuth(reconciledCredentials.token)
return withClaudeUsageMetadata(
oauthRetry,
metadataForAttempt({
attemptedSources: input.attempts.attemptedSources,
oauthCredentials: reconciledCredentials,
authPreparation: input.options?.authPreparation,
source: 'oauth'
})
)
} catch (err) {
warnClaudeUsageFetchFailure(input.options?.authPreparation, reconciledCredentials, err)
return null

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use the reconciled auth preparation for the OAuth retry.

reconcileManagedAuth() may return a fresh ClaudeRuntimeAuthPreparation, but the retry still reads credentials and logs metadata using the pre-reconcile preparation. That can retry against stale config/provenance after reconciliation changes the effective auth state.

Proposed fix
 async function attemptReconcileThenRetryOAuth(input: {
   options?: FetchClaudeRateLimitsOptions
   attempts: ClaudeUsageAttemptState
 }): Promise<ProviderRateLimits | null> {
-  if (!input.options?.reconcileManagedAuth) {
+  const options = input.options
+  if (!options?.reconcileManagedAuth) {
     return null
   }
+  let authPreparation = options.authPreparation
   try {
-    await input.options.reconcileManagedAuth()
+    authPreparation = (await options.reconcileManagedAuth()) ?? authPreparation
   } catch (err) {
-    warnClaudeUsageFetchFailure(input.options?.authPreparation, null, err)
+    warnClaudeUsageFetchFailure(authPreparation, null, err)
     return null
   }
   const reconciledCredentials = await readOAuthCredentials(
-    resolveOAuthCredentialReadOptions(input.options?.authPreparation)
+    resolveOAuthCredentialReadOptions(authPreparation)
   )
   if (!reconciledCredentials.token) {
     return null
   }
@@
         attemptedSources: input.attempts.attemptedSources,
         oauthCredentials: reconciledCredentials,
-        authPreparation: input.options?.authPreparation,
+        authPreparation,
         source: 'oauth'
       })
     )
   } catch (err) {
-    warnClaudeUsageFetchFailure(input.options?.authPreparation, reconciledCredentials, err)
+    warnClaudeUsageFetchFailure(authPreparation, reconciledCredentials, err)
     return null
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
await input.options.reconcileManagedAuth()
} catch (err) {
warnClaudeUsageFetchFailure(input.options?.authPreparation, null, err)
return null
}
const reconciledCredentials = await readOAuthCredentials(
resolveOAuthCredentialReadOptions(input.options?.authPreparation)
)
if (!reconciledCredentials.token) {
return null
}
recordAttempt(input.attempts, 'oauth')
try {
const oauthRetry = await fetchViaOAuth(reconciledCredentials.token)
return withClaudeUsageMetadata(
oauthRetry,
metadataForAttempt({
attemptedSources: input.attempts.attemptedSources,
oauthCredentials: reconciledCredentials,
authPreparation: input.options?.authPreparation,
source: 'oauth'
})
)
} catch (err) {
warnClaudeUsageFetchFailure(input.options?.authPreparation, reconciledCredentials, err)
return null
async function attemptReconcileThenRetryOAuth(input: {
options?: FetchClaudeRateLimitsOptions
attempts: ClaudeUsageAttemptState
}): Promise<ProviderRateLimits | null> {
const options = input.options
if (!options?.reconcileManagedAuth) {
return null
}
let authPreparation = options.authPreparation
try {
authPreparation = (await options.reconcileManagedAuth()) ?? authPreparation
} catch (err) {
warnClaudeUsageFetchFailure(authPreparation, null, err)
return null
}
const reconciledCredentials = await readOAuthCredentials(
resolveOAuthCredentialReadOptions(authPreparation)
)
if (!reconciledCredentials.token) {
return null
}
recordAttempt(input.attempts, 'oauth')
try {
const oauthRetry = await fetchViaOAuth(reconciledCredentials.token)
return withClaudeUsageMetadata(
oauthRetry,
metadataForAttempt({
attemptedSources: input.attempts.attemptedSources,
oauthCredentials: reconciledCredentials,
authPreparation,
source: 'oauth'
})
)
} catch (err) {
warnClaudeUsageFetchFailure(authPreparation, reconciledCredentials, err)
return null
}
}

@OrcaWin

OrcaWin commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator

Closing because the shared-runtime credential architecture this repair targets has been replaced on main (ad2156c3584) by the per-account Claude profile routing introduced in #26801.

I read the complete production/test patch, body, reviews, and inline discussion. The proposed repair adopts a terminal-owned rotation from shared ~/.claude into a separate managed credential snapshot, then retries usage. Current ClaudeRuntimeAuthService publishes profile selection instead of materializing/reading back managed snapshots; ClaudeProfileRouter launches account sessions with their account CLAUDE_CONFIG_DIR and reports profile:<id> provenance. Usage reads the same account folder and scoped Keychain identity, with unsuffixed Keychain fallback reserved for System default. The managed reconcile resolver and old managed store ownership no longer exist.

The seven added regressions specifically exercise the removed shared-store reconciliation and managed resolver contract. Their underlying stale-copy premise is therefore invalidated, rather than merely moved to another file. This does not claim that every possible Claude OAuth/refresh failure is solved; it closes this particular cross-store repair for the retired architecture.

@OrcaWin OrcaWin closed this Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants