diff --git a/src/main/rate-limits/claude-fetcher.test.ts b/src/main/rate-limits/claude-fetcher.test.ts index da85dc69100..96c1c1c75ae 100644 --- a/src/main/rate-limits/claude-fetcher.test.ts +++ b/src/main/rate-limits/claude-fetcher.test.ts @@ -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 | 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 = { @@ -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: { diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index af0563762c2..ba21d32decf 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -158,13 +158,16 @@ async function readFromKeychain(configDir?: string): Promise { + 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 { + 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 @@ -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,