Claude quota: recover the credential from the source it came from (#1516)
Some checks failed
CI / semgrep (push) Waiting to run
Tests / test (22) (push) Waiting to run
Tests / test (22.13.0) (push) Waiting to run
Tests / test (24) (push) Waiting to run
Tests / test (26) (push) Waiting to run
Tests / test-platforms (macos-latest) (push) Waiting to run
Tests / test-platforms (windows-latest) (push) Waiting to run
Tests / desktop (push) Waiting to run
Windows Menubar CI / check (ubuntu-latest) (push) Has been cancelled
Windows Menubar CI / check (windows-latest) (push) Has been cancelled

* fix(quota): recover the Claude credential from the source it came from

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.

* fix(quota): keep keychain reads on forced refresh and hold reconnect prompt

Drop the near-expiry keychain re-read from the desktop provider; a keychain
credential is re-read only after a 401, which only a forced refresh reaches.
Hold terminalFailure through keychainUnchecked background polls.

---------

Co-authored-by: review-bot <resham0209@gmail.com>
This commit is contained in:
ozymandiashh 2026-09-23 19:16:23 +03:00 • committed by GitHub
parent 598e77a808
commit e07985f67a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 233 additions and 13 deletions

View file

@ -1,5 +1,10 @@
# Changelog
## Unreleased
### Fixed
- **An expired Claude Code login is recovered from the Keychain, and `codeburn quota` and the desktop app say so when it cannot be.** Both providers read 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. After a 401 the retry re-read the file whatever the credential had come from, so a Keychain user got `Temporarily unavailable.` until Claude Code was run again, and the desktop Plans card kept showing the last percentage it had fetched. A 401 now re-reads the store the credential came from, which picks up a login Claude Code has since renewed. When nothing newer is there and the credential's `expiresAt` has passed, the row reports a terminal failure with a line telling you to run Claude Code once. The desktop app still reads the Keychain only on Check now or a forced refresh, and a background poll keeps that reconnect prompt instead of falling back to Check now. A 401 on a credential still within its life stays transient with its backoff. (#1516, thanks @ozymandiashh)
## 0.9.25 - 2026-09-21
### Added

View file

@ -149,3 +149,72 @@ describe('Claude keychain fallback', () => {
expect(keychain).not.toHaveBeenCalled()
})
})
// The desktop app reads the same credential the CLI does, and on macOS that is
// normally the Keychain with no credential file at all - which is exactly the
// install the app never writes back to. The 401 re-read has to consult that store.
describe('Claude credential recovery', () => {
const originalPlatform = process.platform
beforeAll(() => Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }))
afterAll(() => Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }))
const now = () => 1_760_000_000_000
const HOUR = 3_600_000
const usage = JSON.stringify({ seven_day: { utilization: 55, resets_at: '2026-07-19T12:00:00Z' } })
const stored = (accessToken: string, expiresAt: number) =>
JSON.stringify({ claudeAiOauth: { accessToken, expiresAt, rateLimitTier: 'max_20x' } })
const ok = () => new Response(usage, { status: 200 })
it('re-reads the keychain, not the absent file, when a 401 rejects the token', async () => {
let reads = 0
const keychain = vi.fn(async () => ({ status: 'found' as const, value: stored(reads++ === 0 ? 'before' : 'after', now() + HOUR) }))
let requests = 0
const fetchMock = vi.fn(async () => (requests++ === 0 ? new Response('', { status: 401 }) : ok()))
const readFile = vi.fn(async () => null)
const result = await fetchClaudeQuota({ fetch: fetchMock, readFile, keychain, allowKeychain: true, now })
expect(result.quota.connection).toBe('connected')
expect(result.quota.primary?.percent).toBe(0.55)
expect(keychain).toHaveBeenCalledTimes(2)
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('calls an expired login terminal when a 401 rejects it and the keychain has nothing newer', async () => {
const keychain = vi.fn(async () => ({ status: 'found' as const, value: stored('unchanged', now() - HOUR) }))
const fetchMock = vi.fn(async () => new Response('', { status: 401 }))
const result = await fetchClaudeQuota({ fetch: fetchMock, readFile: vi.fn(async () => null), keychain, allowKeychain: true, now })
expect(result.quota.connection).toBe('terminalFailure')
expect(result.quota.footerLines[0]).toMatch(/expired/i)
expect(result.quota.connectable).toBe(true)
expect(keychain).toHaveBeenCalledTimes(2)
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('keeps a 401 transient while the credential is still within its life', async () => {
const keychain = vi.fn(async () => ({ status: 'found' as const, value: stored('unchanged', now() + HOUR) }))
const fetchMock = vi.fn(async () => new Response('', { status: 401 }))
const result = await fetchClaudeQuota({ fetch: fetchMock, readFile: vi.fn(async () => null), keychain, allowKeychain: true, now })
expect(result.quota.connection).toBe('transientFailure')
expect(result.quota.footerLines).toEqual([])
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('re-reads the file for a file-backed credential on a background poll and never touches the keychain', async () => {
let reads = 0
const readFile = vi.fn(async () => stored(reads++ === 0 ? 'before' : 'after', now() + HOUR))
let requests = 0
const fetchMock = vi.fn(async () => (requests++ === 0 ? new Response('', { status: 401 }) : ok()))
const keychain = vi.fn(async () => ({ status: 'found' as const, value: stored('keychain', now() + HOUR) }))
const result = await fetchClaudeQuota({ fetch: fetchMock, readFile, keychain, now })
expect(result.quota.connection).toBe('connected')
expect(readFile).toHaveBeenCalledTimes(2)
expect(keychain).not.toHaveBeenCalled()
})
})

View file

@ -7,8 +7,11 @@ import type { QuotaProvider, QuotaWindow } from './types'
const ENDPOINT = 'https://api.anthropic.com/api/oauth/usage'
const KEYCHAIN_SERVICE = 'Claude Code-credentials'
const EXPIRED_FOOTER = ['Claude Code login expired. Run Claude Code once, then refresh.']
type ClaudeCredential = { accessToken: string; expiresAt?: number; rateLimitTier?: string; subscriptionType?: string }
/** Where the credential in hand came from, so a rejected token is re-read from that same place. */
type CredentialSource = 'file' | 'keychain'
export type ClaudeDeps = {
fetch: typeof fetch
credentialPath: string
@ -24,8 +27,8 @@ const defaults: ClaudeDeps = {
now: Date.now,
}
function empty(connection: QuotaProvider['connection']): QuotaProvider {
return { provider: 'claude', connection, primary: null, details: [], planLabel: null, footerLines: [] }
function empty(connection: QuotaProvider['connection'], footerLines: string[] = []): QuotaProvider {
return { provider: 'claude', connection, primary: null, details: [], planLabel: null, footerLines }
}
function parseCredential(raw: string): ClaudeCredential | null {
@ -45,6 +48,35 @@ async function credentialFromFile(deps: ClaudeDeps): Promise<ClaudeCredential |
return raw ? parseCredential(raw) : null
}
/**
* Re-read whichever store the credential came from. On macOS the credential
* usually lives in the Keychain and no file exists at all, so re-reading the
* file could never see the token Claude Code has since renewed - and the
* desktop app never writes that Keychain item itself. A denied Keychain on a
* re-read needs no state of its own: the first read already decided whether
* that store is reachable.
*/
async function credentialFrom(source: CredentialSource, deps: ClaudeDeps): Promise<ClaudeCredential | null> {
if (source === 'file') return credentialFromFile(deps)
const outcome = await (deps.keychain ?? readClaudeKeychain)()
return outcome.status === 'found' ? parseCredential(outcome.value) : null
}
/**
* What a credential the endpoint will not honour, and which its own store cannot
* replace, should be reported as. An expired login is terminal: nothing but a
* fresh `claude` run can clear it, and a transient failure would leave the quota
* card holding the last connected numbers (`stabilizeQuota` in Plans.tsx) as
* though they were current. A credential still within its life is a real blip
* and keeps its backoff.
*/
function unrecoverable(credential: ClaudeCredential, deps: ClaudeDeps): QuotaProvider {
const expired = credential.expiresAt !== undefined && credential.expiresAt <= deps.now()
// An expired login is connectable here the way Kimi's and Gemini's are: the
// Plans card turns that flag into the reconnect affordance that clears it.
return expired ? { ...empty('terminalFailure', EXPIRED_FOOTER), connectable: true } : empty('transientFailure')
}
export async function readClaudeKeychain(): Promise<KeychainOutcome> {
// Claude Code has written the item under both `$USER` (2.1.x) and the older
// hardcoded "agentseal" account; a user-scoped miss must fall through to the
@ -127,10 +159,12 @@ export async function fetchClaudeQuota(options: Partial<ClaudeDeps> & { signal?:
const deps = { ...defaults, ...options }
try {
let credential = await credentialFromFile(deps)
let source: CredentialSource = 'file'
if (!credential && options.allowKeychain && process.platform === 'darwin') {
const outcome = await (deps.keychain ?? readClaudeKeychain)()
if (outcome.status === 'accessDenied') return { quota: empty('accessDenied') }
credential = outcome.status === 'found' ? parseCredential(outcome.value) : null
source = 'keychain'
}
// A Claude Code 2.x login lives only in the macOS keychain: no credentials
// file at all. Since a background poll never reads the keychain, "no file"
@ -141,15 +175,17 @@ export async function fetchClaudeQuota(options: Partial<ClaudeDeps> & { signal?:
}
let response: Response
if (credential.expiresAt !== undefined && credential.expiresAt - deps.now() <= 5 * 60_000) {
// A keychain read can raise a macOS prompt, so a keychain credential is only
// re-read after the endpoint has actually rejected it.
if (source === 'file' && credential.expiresAt !== undefined && credential.expiresAt - deps.now() <= 5 * 60_000) {
const reread = await credentialFromFile(deps)
if (!reread || reread.accessToken === credential.accessToken) return { quota: empty('transientFailure') }
if (!reread || reread.accessToken === credential.accessToken) return { quota: unrecoverable(credential, deps) }
credential = reread
}
response = await request(credential.accessToken, deps, options.signal)
if (response.status === 401) {
const reread = await credentialFromFile(deps)
if (!reread || reread.accessToken === credential.accessToken) return { quota: empty('transientFailure') }
const reread = await credentialFrom(source, deps)
if (!reread || reread.accessToken === credential.accessToken) return { quota: unrecoverable(credential, deps) }
credential = reread
response = await request(credential.accessToken, deps, options.signal)
}

View file

@ -129,6 +129,21 @@ describe('QuotaService', () => {
expect(results.find(row => row.provider === 'claude')?.connection).toBe('accessDenied')
})
it('holds an expired login\'s reconnect prompt through an unchecked-keychain background poll', async () => {
const fetchers = noopFetchers()
let now = 1000
const service = new QuotaService({
...fetchers, grokbotInstalled: () => true, now: () => now,
readFile: vi.fn(async () => null), writeFile: vi.fn(async () => undefined),
})
fetchers.claude.mockResolvedValue({ quota: { ...quota('claude'), connection: 'terminalFailure', connectable: true } })
await service.getQuota({ force: true, allowKeychain: true })
fetchers.claude.mockResolvedValue({ quota: { ...quota('claude'), connection: 'keychainUnchecked' } })
now += 10 * 60_000
const results = await service.getQuota({})
expect(results.find(row => row.provider === 'claude')?.connection).toBe('terminalFailure')
})
// The snap declares no Codex credential path, because the live gauge would
// need write access to the Codex CLI's own auth.json to rotate the token.
// Under $SNAP the Codex fetch must not run at all; Claude is unaffected.

View file

@ -150,9 +150,10 @@ export class QuotaService {
// look", which is never news: it must not overwrite what a forced check
// established — neither a live connection nor the keychain-denied
// guidance, which would otherwise be replaced by "Check now" one poll
// after the user denied it.
// after the user denied it, nor an expired login's reconnect prompt.
if (!allowKeychain && next.connection === 'keychainUnchecked'
&& (previous?.connection === 'connected' || previous?.connection === 'accessDenied')) return previous
&& (previous?.connection === 'connected' || previous?.connection === 'accessDenied'
|| previous?.connection === 'terminalFailure')) return previous
if (previous?.connection !== 'connected') return next
// Keychain-only credentials are invisible to a background (keychain-less)
// poll; keep showing the live connection rather than flapping to

View file

@ -7,23 +7,28 @@ import type { QuotaProvider, QuotaWindow } from './types.js'
const ENDPOINT = 'https://api.anthropic.com/api/oauth/usage'
const KEYCHAIN_SERVICE = 'Claude Code-credentials'
const EXPIRED_FOOTER = ['Claude Code login expired. Run Claude Code once, then refresh.']
type ClaudeCredential = { accessToken: string; expiresAt?: number; rateLimitTier?: string; subscriptionType?: string }
/** Where the credential in hand came from, so a rejected token is re-read from that same place. */
type CredentialSource = 'file' | 'keychain'
export type ClaudeDeps = {
fetch: typeof fetch
credentialPath: string
readFile: typeof readSecureFile
keychain?: () => Promise<KeychainOutcome>
now: () => number
}
const defaults: ClaudeDeps = {
fetch: globalThis.fetch,
credentialPath: path.join(os.homedir(), '.claude', '.credentials.json'),
readFile: readSecureFile,
now: Date.now,
}
function empty(connection: QuotaProvider['connection']): QuotaProvider {
return { provider: 'claude', connection, primary: null, details: [], planLabel: null, footerLines: [] }
function empty(connection: QuotaProvider['connection'], footerLines: string[] = []): QuotaProvider {
return { provider: 'claude', connection, primary: null, details: [], planLabel: null, footerLines }
}
function parseCredential(raw: string): ClaudeCredential | null {
@ -43,6 +48,19 @@ async function credentialFromFile(deps: ClaudeDeps): Promise<ClaudeCredential |
return raw ? parseCredential(raw) : null
}
/**
* Re-read whichever store the credential came from. On macOS the credential
* usually lives in the Keychain and no file exists at all, so re-reading the
* file after a 401 could never see the token Claude Code has since renewed.
* A denied Keychain on a re-read needs no state of its own: the first read
* already decided whether that store is reachable.
*/
async function credentialFrom(source: CredentialSource, deps: ClaudeDeps): Promise<ClaudeCredential | null> {
if (source === 'file') return credentialFromFile(deps)
const outcome = await (deps.keychain ?? readClaudeKeychain)()
return outcome.status === 'found' ? parseCredential(outcome.value) : null
}
export async function readClaudeKeychain(): Promise<KeychainOutcome> {
// Claude Code has written the item under both `$USER` (2.1.x) and the older
// hardcoded "agentseal" account; a user-scoped miss must fall through to the
@ -125,17 +143,26 @@ export async function fetchClaudeQuota(options: Partial<ClaudeDeps> & { signal?:
const deps = { ...defaults, ...options }
try {
let credential = await credentialFromFile(deps)
let source: CredentialSource = 'file'
if (!credential && options.allowKeychain && process.platform === 'darwin') {
const outcome = await (deps.keychain ?? readClaudeKeychain)()
if (outcome.status === 'accessDenied') return { quota: empty('accessDenied') }
credential = outcome.status === 'found' ? parseCredential(outcome.value) : null
source = 'keychain'
}
if (!credential) return { quota: empty('disconnected') }
let response = await request(credential.accessToken, deps, options.signal)
if (response.status === 401) {
const reread = await credentialFromFile(deps)
if (!reread || reread.accessToken === credential.accessToken) return { quota: empty('transientFailure') }
const reread = await credentialFrom(source, deps)
if (!reread || reread.accessToken === credential.accessToken) {
// Nothing but a fresh login can clear a 401 on a credential whose life is
// already over, so it is reported as terminal rather than as the blip its
// consumers are meant to retry and to keep their last numbers through. A
// token that has not expired is a real blip and keeps that backoff.
const expired = credential.expiresAt !== undefined && credential.expiresAt <= deps.now()
return { quota: expired ? empty('terminalFailure', EXPIRED_FOOTER) : empty('transientFailure') }
}
credential = reread
response = await request(credential.accessToken, deps, options.signal)
}

View file

@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { fetchAntigravityQuota, decodeAntigravitySummary, parseNetstatPorts } from '../src/quota/antigravity.js'
import { decodeClaudeUsage, fetchClaudeQuota, planLabel } from '../src/quota/claude.js'
@ -45,6 +45,73 @@ describe('Claude quota', () => {
expect(planLabel({ subscriptionType, rateLimitTier })).toBe(label)
}
})
// A 401 has to be answered by re-reading the store the credential actually came
// from - on macOS that is usually the Keychain, and no credential file exists at
// all - and a credential whose life is over has to read as terminal rather than as
// a blip worth retrying.
describe('after a rejected credential', () => {
const realPlatform = process.platform
beforeAll(() => Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }))
afterAll(() => Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true }))
const now = () => 1_760_000_000_000
const HOUR = 3_600_000
const usage = JSON.stringify({ seven_day: { utilization: 55, resets_at: '2026-07-19T12:00:00Z' } })
const stored = (accessToken: string, expiresAt: number) =>
JSON.stringify({ claudeAiOauth: { accessToken, expiresAt, rateLimitTier: 'max_20x' } })
it('re-reads the keychain, not the absent file, and adopts the renewed login', async () => {
let reads = 0
const keychain = vi.fn(async () => ({ status: 'found' as const, value: stored(reads++ === 0 ? 'before' : 'after', now() + HOUR) }))
let requests = 0
const fetchMock = vi.fn(async () => new Response(usage, { status: requests++ === 0 ? 401 : 200 }))
const readFile = vi.fn(async () => null)
const result = await fetchClaudeQuota({ fetch: fetchMock as unknown as typeof fetch, readFile, keychain, allowKeychain: true, now })
expect(result.quota.connection).toBe('connected')
expect(result.quota.primary?.percent).toBe(0.55)
expect(keychain).toHaveBeenCalledTimes(2)
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('reports a terminal failure when the login behind it has expired', async () => {
const keychain = vi.fn(async () => ({ status: 'found' as const, value: stored('unchanged', now() - HOUR) }))
const fetchMock = vi.fn(async () => new Response('', { status: 401 }))
const result = await fetchClaudeQuota({ fetch: fetchMock as unknown as typeof fetch, readFile: noFile, keychain, allowKeychain: true, now })
expect(result.quota.connection).toBe('terminalFailure')
expect(result.quota.footerLines[0]).toMatch(/expired/i)
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('keeps a transient failure while the credential is still within its life', async () => {
const keychain = vi.fn(async () => ({ status: 'found' as const, value: stored('unchanged', now() + HOUR) }))
const fetchMock = vi.fn(async () => new Response('', { status: 401 }))
const result = await fetchClaudeQuota({ fetch: fetchMock as unknown as typeof fetch, readFile: noFile, keychain, allowKeychain: true, now })
expect(result.quota.connection).toBe('transientFailure')
expect(result.quota.footerLines).toEqual([])
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('re-reads the file for a file-backed credential and never touches the keychain', async () => {
let reads = 0
const readFile = vi.fn(async () => stored(reads++ === 0 ? 'before' : 'after', now() + HOUR))
let requests = 0
const fetchMock = vi.fn(async () => new Response(usage, { status: requests++ === 0 ? 401 : 200 }))
const keychain = vi.fn(async () => ({ status: 'found' as const, value: stored('keychain', now() + HOUR) }))
const result = await fetchClaudeQuota({ fetch: fetchMock as unknown as typeof fetch, readFile, keychain, allowKeychain: true, now })
expect(result.quota.connection).toBe('connected')
expect(readFile).toHaveBeenCalledTimes(2)
expect(keychain).not.toHaveBeenCalled()
})
})
})
describe('Codex quota', () => {