Skip to content

Claude quota: recover the credential from the source it came from - #1516

Open
ozymandiashh wants to merge 1 commit into
getagentseal:mainfrom
ozymandiashh:fix/claude-keychain-credential-recovery
Open

ozymandiashh wants to merge 1 commit into
getagentseal:mainfrom
ozymandiashh:fix/claude-keychain-credential-recovery

Conversation

@ozymandiashh

Copy link
Copy Markdown
Collaborator

Fixes #1515

The bug

fetchClaudeQuota resolves the OAuth credential from ~/.claude/.credentials.json first and falls back to the macOS Keychain, which is where Claude Code actually keeps it on a normal macOS install — that file does not exist there. When the access token expires the usage endpoint answers 401, and the recovery branch re-read the file regardless of where the credential had come from. The re-read is therefore always null, every refresh ends in transientFailure, and the Keychain — the only place a renewed token can appear — is never read again on that path.

app/electron/quota/claude.ts, the desktop app's own near-verbatim copy, has the same dead end in two places: the 401 branch and the proactive branch that fires when the credential is within five minutes of expiresAt.

Measured on macOS with Claude Code 2.1.x on Max 20x: codeburn quota printed Temporarily unavailable. for the seven hours between the token expiring and the claude CLI being run again, and was correct on the very next call once that rewrote the Keychain item — no restart, the credential had been reachable the whole time.

It is worse than a wrong error string on the desktop side, where stabilizeQuota holds a provider's last connected numbers through a transientFailure: the Plans card keeps presenting the last percentage it fetched as current, for a credential that no amount of retrying can refresh.

The change

  • The credential carries which source it came from, and every recovery branch re-reads that source.
  • When the re-read yields the same credential (or none) and that credential is expired, the provider reports terminalFailure with a footer line saying the saved login has expired, instead of transientFailure. A 401 on a credential that has not expired stays transientFailure with its existing backoff.
  • The Electron copy marks that terminal state connectable: true, matching kimi.ts and gemini.ts, so Plans.tsx still offers the reconnect affordance that clears it.

This is the policy ClaudeCredentialStore already implements natively — freshAccessToken() and refreshAfter401() re-read Claude's own store for a token the CLI has rotated, and deliberately never spend the shared refresh token, which would break Claude Code's login. Nothing here rotates anything either; the TypeScript copies just never got the "re-read the source you came from" half.

No new network calls, no new UI surface, TypeScript only. The macOS menu bar's Claude tile is fed by the native Swift path and is untouched by this.

Tests

tests/quota-providers.test.ts gains four cases and app/electron/quota/claude.test.ts six, all driving injected fetch, readFile, keychain and now, with every expiresAt relative to the injected clock. Each case that describes the bug fails on the unpatched provider for the reason it claims — stashing src/quota/claude.ts turns the renewed-token case into expected 'transientFailure' to be 'connected' and the expired case into expected 'transientFailure' to be 'terminalFailure'; stashing the Electron copy fails four, one pair per branch. The file-backed cases pass before and after: they are there to prove the file path did not move.

npx vitest run tests/quota-providers.test.ts tests/quota.test.ts → 41 passed. npx vitest run electron/quota from app/ → 156 passed. npx tsc --noEmit -p tsconfig.json → clean.

Both Claude quota providers resolve the OAuth credential from
~/.claude/.credentials.json and fall back to the macOS Keychain, which is
where Claude Code keeps it on a normal Mac. Every recovery re-read went to
the file whatever the credential had come from, so for a Keychain user it
read nothing and the one place a renewed token can appear was never
consulted: a lapsed login left the retry a dead end indefinitely.

A recovery now re-reads whichever store the credential came from. When it
cannot recover - the credential is unchanged and its expiresAt has passed -
the provider reports terminalFailure with a line telling the user to run
Claude Code once, rather than the transientFailure the desktop app's quota
cards hold the last connected numbers through. A 401 on a credential still
within its life keeps its transient handling and its backoff.

The desktop app carries its own copy of this provider, where the same
file-only re-read sat in two branches - the 401 and the one that re-reads a
credential within five minutes of expiry - and both are fixed.
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.

Claude quota can never recover from an expired Keychain credential: the 401 path re-reads a file that does not exist on macOS

1 participant