Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions src/main/rate-limits/claude-fetcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,169 @@ describe('fetchClaudeRateLimits', () => {
)
})

it('falls back to the legacy keychain token when the scoped token is rejected as stale', async () => {
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
configDir,
envPatch: { CLAUDE_CONFIG_DIR: configDir },
stripAuthEnv: false,
provenance: 'system'
}
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (dir) =>
dir
? JSON.stringify({
claudeAiOauth: { accessToken: 'stale-scoped-token', refreshToken: 'refresh-1' }
})
: JSON.stringify({ claudeAiOauth: { accessToken: 'fresh-legacy-token' } })
)
netFetchMock.mockImplementation(async (_url: string, init?: RequestInit) => {
const auth = (init?.headers as Record<string, string> | undefined)?.Authorization
return auth === 'Bearer fresh-legacy-token'
? new Response(
JSON.stringify({ five_hour: { utilization: 12 }, seven_day: { utilization: 34 } }),
{ status: 200 }
)
: new Response(
JSON.stringify({ error: { message: 'Invalid authentication credentials' } }),
{ status: 401 }
)
})

await expect(
fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false })
).resolves.toMatchObject({
provider: 'claude',
status: 'ok',
session: { usedPercent: 12 },
weekly: { usedPercent: 34 }
})

expect(netFetchMock).toHaveBeenCalledTimes(2)
expect(fetchViaPty).not.toHaveBeenCalled()
})

it('prefers a legacy access token over scoped refresh-only credentials', async () => {
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
configDir,
envPatch: { CLAUDE_CONFIG_DIR: configDir },
stripAuthEnv: false,
provenance: 'system'
}
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (dir) =>
dir
? JSON.stringify({ claudeAiOauth: { refreshToken: 'refresh-only' } })
: JSON.stringify({ claudeAiOauth: { accessToken: 'fresh-legacy-token' } })
)

await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({
provider: 'claude',
status: 'ok',
session: { usedPercent: 12 }
})

expect(netFetchMock).toHaveBeenCalledTimes(1)
expect(netFetchMock).toHaveBeenCalledWith(
'https://api.anthropic.com/api/oauth/usage',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer fresh-legacy-token' })
})
)
})

it('does not retry with the legacy keychain for managed account credentials', async () => {
const configDir = '/Users/test/managed-account'
const authPreparation: ClaudeRuntimeAuthPreparation = {
configDir,
envPatch: { CLAUDE_CONFIG_DIR: configDir },
stripAuthEnv: true,
provenance: 'managed:account-1'
}
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (dir) =>
dir ? JSON.stringify({ claudeAiOauth: { accessToken: 'stale-managed-token' } }) : null
)
netFetchMock.mockResolvedValue(
new Response(JSON.stringify({ error: { message: 'Invalid authentication credentials' } }), {
status: 401
})
)

await expect(
fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false })
).resolves.toMatchObject({
provider: 'claude',
status: 'error'
})

expect(netFetchMock).toHaveBeenCalledTimes(1)
expect(readActiveClaudeKeychainCredentialsStrict).not.toHaveBeenCalledWith(undefined)
})

it('does not retry with the legacy keychain for WSL targets', async () => {
// Why: a WSL target's stale credentials must never be answered with the
// host user's legacy macOS Keychain account.
const configDir = '\\\\wsl$\\Ubuntu\\home\\test\\.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
configDir,
runtime: 'wsl',
wslDistro: 'Ubuntu',
wslLinuxConfigDir: '/home/test/.claude',
envPatch: { CLAUDE_CONFIG_DIR: '/home/test/.claude' },
stripAuthEnv: false,
provenance: 'system'
}
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (dir) =>
dir
? JSON.stringify({ claudeAiOauth: { accessToken: 'stale-wsl-token' } })
: JSON.stringify({ claudeAiOauth: { accessToken: 'fresh-legacy-token' } })
)
netFetchMock.mockResolvedValue(
new Response(JSON.stringify({ error: { message: 'Invalid authentication credentials' } }), {
status: 401
})
)

await expect(
fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false })
).resolves.toMatchObject({
provider: 'claude',
status: 'error'
})

expect(netFetchMock).toHaveBeenCalledTimes(1)
expect(readActiveClaudeKeychainCredentialsStrict).not.toHaveBeenCalledWith(undefined)
})

it('skips the legacy retry when the legacy item mirrors the failed scoped token', async () => {
// Why: Claude's usage endpoint has a tight request budget; retrying the
// identical token would double the request for a guaranteed second 401.
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
configDir,
envPatch: { CLAUDE_CONFIG_DIR: configDir },
stripAuthEnv: false,
provenance: 'system'
}
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue(
JSON.stringify({ claudeAiOauth: { accessToken: 'mirrored-token' } })
)
netFetchMock.mockResolvedValue(
new Response(JSON.stringify({ error: { message: 'Invalid authentication credentials' } }), {
status: 401
})
)

await expect(
fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false })
).resolves.toMatchObject({
provider: 'claude',
status: 'error'
})

expect(netFetchMock).toHaveBeenCalledTimes(1)
expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(undefined)
})

it('accepts Claude Code statusline-style rate limit window fields', async () => {
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
Expand Down Expand Up @@ -734,6 +897,9 @@ describe('fetchClaudeRateLimits', () => {
}
})
)
// Legacy item absent — the stale-scoped legacy fallback must not preempt
// CLI repair in this scenario.
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(
JSON.stringify({
claudeAiOauth: {
Expand Down
127 changes: 102 additions & 25 deletions src/main/rate-limits/claude-fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,13 +158,16 @@ async function readFromKeychain(configDir?: string): Promise<OAuthCredentialRead
if (scopedCredentials.token) {
return scopedCredentials
}
if (scopedCredentials.hasRefreshableCredentials) {
return scopedCredentials
}
const legacyCredentials = await readCredentialsFromStrictKeychain(undefined, 'legacy-keychain')
// Why: Orca cannot refresh tokens itself, so an actual access token from
// either item beats refresh-only credentials. A scoped item the CLI stopped
// maintaining must not shadow a still-working legacy token.
if (legacyCredentials.token) {
return legacyCredentials
}
if (scopedCredentials.hasRefreshableCredentials) {
return scopedCredentials
}
if (legacyCredentials.hasRefreshableCredentials) {
return legacyCredentials
}
Expand Down Expand Up @@ -637,6 +640,84 @@ async function supplementOAuthUsageFromCli(input: {
}
}

async function completeOAuthUsageSuccess(input: {
oauthLimits: ProviderRateLimits
oauthCredentials: OAuthCredentialReadResult
attempts: ClaudeUsageAttemptState
options?: FetchClaudeRateLimitsOptions
}): Promise<ProviderRateLimits> {
const limits = await supplementOAuthUsageFromCli({
oauthLimits: input.oauthLimits,
authPreparation: input.options?.authPreparation,
oauthCredentials: input.oauthCredentials,
attempts: input.attempts,
networkProxySettings: input.options?.networkProxySettings,
allowUsagePanelSupplement:
input.options?.allowUsagePanelSupplement ??
isManagedClaudeAuth(input.options?.authPreparation),
signal: input.options?.signal
})
if (input.options?.signal?.aborted) {
return abortedClaudeRateLimitResult()
}
return withClaudeUsageMetadata(
limits,
metadataForAttempt({
attemptedSources: input.attempts.attemptedSources,
oauthCredentials: input.oauthCredentials,
authPreparation: input.options?.authPreparation,
source: 'oauth'
})
)
}

function canRetryWithLegacyKeychainToken(input: {
classification: ClaudeUsageErrorClassification
oauthCredentials: OAuthCredentialReadResult
authPreparation?: ClaudeRuntimeAuthPreparation
}): boolean {
// Why: the CLI only maintains the legacy keychain item for the default config
// dir, so a scoped item can hold a token that expired long ago and will 401
// on every fetch with no recovery path. Host system-default auth may retry
// with the legacy item; managed/WSL credentials must never be answered with
// the host user's legacy keychain account.
return (
input.classification.failureKind === 'stale-token' &&
input.oauthCredentials.source === 'scoped-keychain' &&
(input.authPreparation?.runtime ?? 'host') === 'host' &&
!isManagedClaudeAuth(input.authPreparation)
)
}

async function retryOAuthWithLegacyKeychainToken(input: {
failedToken: string | null
attempts: ClaudeUsageAttemptState
options?: FetchClaudeRateLimitsOptions
}): Promise<ProviderRateLimits | null> {
const legacyCredentials = await readCredentialsFromStrictKeychain(undefined, 'legacy-keychain')
if (!legacyCredentials.token || legacyCredentials.token === input.failedToken) {
return null
}
if (input.options?.signal?.aborted) {
return abortedClaudeRateLimitResult()
}
try {
const oauthLimits = await fetchViaOAuth(legacyCredentials.token, input.options?.signal)
if (input.options?.signal?.aborted) {
return abortedClaudeRateLimitResult()
}
return await completeOAuthUsageSuccess({
oauthLimits,
oauthCredentials: legacyCredentials,
attempts: input.attempts,
options: input.options
})
} catch (err) {
warnClaudeUsageFetchFailure(input.options?.authPreparation, legacyCredentials, err)
return null
}
}

function shouldDeferForLiveClaude(
authPreparation: ClaudeRuntimeAuthPreparation | undefined,
classification: ClaudeUsageErrorClassification
Expand Down Expand Up @@ -798,32 +879,28 @@ export async function fetchClaudeRateLimits(
if (options?.signal?.aborted) {
return abortedClaudeRateLimitResult()
}
const limits = await supplementOAuthUsageFromCli({
oauthLimits,
authPreparation: options?.authPreparation,
oauthCredentials,
attempts,
networkProxySettings: options?.networkProxySettings,
allowUsagePanelSupplement:
options?.allowUsagePanelSupplement ?? isManagedClaudeAuth(options?.authPreparation),
signal: options?.signal
})
if (options?.signal?.aborted) {
return abortedClaudeRateLimitResult()
}
return withClaudeUsageMetadata(
limits,
metadataForAttempt({
attemptedSources: attempts.attemptedSources,
oauthCredentials,
authPreparation: options?.authPreparation,
source: 'oauth'
})
)
return await completeOAuthUsageSuccess({ oauthLimits, oauthCredentials, attempts, options })
} catch (err) {
warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, err)
const classification = classifyClaudeOAuthUsageError(err)

if (
canRetryWithLegacyKeychainToken({
classification,
oauthCredentials,
authPreparation: options?.authPreparation
})
) {
const legacyResult = await retryOAuthWithLegacyKeychainToken({
failedToken: oauthCredentials.token,
attempts,
options
})
if (legacyResult) {
return legacyResult
}
}

if (shouldDeferForLiveClaude(options?.authPreparation, classification)) {
return liveClaudeDeferredResult({
attempts,
Expand Down
Loading