mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-21 05:23:19 +00:00
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
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:
parent
598e77a808
commit
e07985f67a
7 changed files with 233 additions and 13 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue