From 03c18c4373e7eee3ca700e73c00d19bc12f09074 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:05:32 -0700 Subject: [PATCH] Diagnose Claude usage refresh auth failures (#5614) Co-authored-by: Orca --- .../runtime-auth-service.test.ts | 3 +- .../claude-accounts/runtime-auth-service.ts | 14 ++- src/main/rate-limits/claude-fetcher.test.ts | 42 +++++++++ src/main/rate-limits/claude-fetcher.ts | 88 ++++++++++++++++--- .../src/components/status-bar/tooltip.test.ts | 11 +++ 5 files changed, 143 insertions(+), 15 deletions(-) diff --git a/src/main/claude-accounts/runtime-auth-service.test.ts b/src/main/claude-accounts/runtime-auth-service.test.ts index 4e03913f16b..49c84aed0ba 100644 --- a/src/main/claude-accounts/runtime-auth-service.test.ts +++ b/src/main/claude-accounts/runtime-auth-service.test.ts @@ -3692,10 +3692,11 @@ describe('ClaudeRuntimeAuthService', () => { markClaudePtySpawned('pty-live-1') try { - await service.syncForCurrentSelection() + const preparation = await service.prepareForRateLimitFetch() // A live Claude owns the credentials; refreshing here would race its // rotation, so the proactive refresh must be skipped entirely. expect(refreshClaudeOauthCredentials).not.toHaveBeenCalled() + expect(preparation.managedRefreshDeferredByLivePty).toBe(true) } finally { markClaudePtyExited('pty-live-1') vi.mocked(isOauthTokenExpiring).mockReturnValue(false) diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index da283d60d25..3b50a4b142b 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -44,6 +44,7 @@ export type ClaudeRuntimeAuthPreparation = { wslLinuxConfigDir?: string | null envPatch: ClaudeEnvPatch stripAuthEnv: boolean + managedRefreshDeferredByLivePty?: boolean provenance: string } @@ -101,6 +102,7 @@ export class ClaudeRuntimeAuthService { private hasLastWrittenOauthAccount = false private lastWrittenOauthAccount: unknown = null private skipNextReadBackForAccountId: string | null = null + private managedRefreshDeferredByLivePtyAccountId: string | null = null constructor(private readonly store: Store) { this.initializeLastSyncedState() @@ -177,6 +179,7 @@ export class ClaudeRuntimeAuthService { settings.claudeManagedAccounts, this.lastSyncedAccountId ) + this.managedRefreshDeferredByLivePtyAccountId = null const previousManagedCredentialsJson = previousAccount ? await this.readManagedCredentials(previousAccount) : null @@ -412,7 +415,11 @@ export class ClaudeRuntimeAuthService { // entirely while a Claude PTY is live: that process owns the credentials // and refreshing here would race its own rotation (double-rotation // invalidates one copy) — the read-back above preserves its refresh instead. - if (!hasLiveClaudePtys()) { + const liveClaudePtys = hasLiveClaudePtys() + if (liveClaudePtys && isOauthTokenExpiring(credentialsJson)) { + this.managedRefreshDeferredByLivePtyAccountId = activeAccount.id + } + if (!liveClaudePtys) { const refreshed = await this.refreshManagedAccountTokenIfNeeded( activeAccount, credentialsJson @@ -677,6 +684,11 @@ export class ClaudeRuntimeAuthService { wslLinuxConfigDir: null, envPatch: paths.envPatch, stripAuthEnv: Boolean(activeAccountId && activeAccount?.managedAuthRuntime !== 'wsl'), + managedRefreshDeferredByLivePty: Boolean( + activeAccountId && + activeAccount?.managedAuthRuntime !== 'wsl' && + this.managedRefreshDeferredByLivePtyAccountId === activeAccountId + ), provenance: activeAccountId && activeAccount?.managedAuthRuntime !== 'wsl' ? `managed:${activeAccountId}` diff --git a/src/main/rate-limits/claude-fetcher.test.ts b/src/main/rate-limits/claude-fetcher.test.ts index c58ee6fd9ec..266492a214e 100644 --- a/src/main/rate-limits/claude-fetcher.test.ts +++ b/src/main/rate-limits/claude-fetcher.test.ts @@ -410,6 +410,48 @@ describe('fetchClaudeRateLimits', () => { expect(fetchViaPty).not.toHaveBeenCalled() }) + it('explains auth failures when a live Claude terminal owns managed refresh', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + managedRefreshDeferredByLivePty: true, + provenance: 'managed:account-1' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'stale-oauth-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() - 60_000 + } + }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: { + type: 'authentication_error', + message: 'Invalid OAuth token.' + } + }), + { status: 401 } + ) + ) + + await expect( + fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'error', + error: + 'Claude usage refresh is waiting for the live Claude terminal to rotate its credentials.' + }) + + expect(fetchViaPty).not.toHaveBeenCalled() + }) + it('does not start the PTY fallback when disabled for background fetches', async () => { const configDir = '/Users/test/.claude' const authPreparation: ClaudeRuntimeAuthPreparation = { diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index e2c2e888256..eae90e0ee01 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -33,6 +33,8 @@ const OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage' const OAUTH_BETA_HEADER = 'oauth-2025-04-20' const CLAUDE_CODE_USER_AGENT = 'claude-code/2.1.0' const API_TIMEOUT_MS = 10_000 +const LIVE_CLAUDE_REFRESH_DEFERRED_MESSAGE = + 'Claude usage refresh is waiting for the live Claude terminal to rotate its credentials.' /** * Bridge standard HTTP proxy env vars into Electron's session proxy config. @@ -66,6 +68,7 @@ type KeychainCredentials = { type OAuthCredentialReadResult = { token: string | null hasRefreshableCredentials: boolean + source: OAuthCredentialSource } type OAuthCredentialReadOptions = { @@ -73,9 +76,14 @@ type OAuthCredentialReadOptions = { keychainConfigDir?: string } +type OAuthCredentialSource = 'scoped-keychain' | 'legacy-keychain' | 'credentials-file' | 'none' + // Why: factored out so both the active-account Keychain reader and the // managed-account reader share the same JSON parsing + refreshability check. -function parseOAuthCredentialsJson(raw: string): OAuthCredentialReadResult { +function parseOAuthCredentialsJson( + raw: string, + source: OAuthCredentialSource +): OAuthCredentialReadResult { try { const parsed = JSON.parse(raw) as KeychainCredentials const oauth = parsed?.claudeAiOauth @@ -85,7 +93,8 @@ function parseOAuthCredentialsJson(raw: string): OAuthCredentialReadResult { if (!token || typeof token !== 'string') { return { token: null, - hasRefreshableCredentials + hasRefreshableCredentials, + source } } // Why: Claude's local expiresAt metadata is not authoritative for the @@ -93,7 +102,8 @@ function parseOAuthCredentialsJson(raw: string): OAuthCredentialReadResult { // observed authenticating there after expiresAt, so let the server decide. return { token, - hasRefreshableCredentials + hasRefreshableCredentials, + source } } catch { return emptyOAuthCredentialReadResult() @@ -103,7 +113,8 @@ function parseOAuthCredentialsJson(raw: string): OAuthCredentialReadResult { function emptyOAuthCredentialReadResult(): OAuthCredentialReadResult { return { token: null, - hasRefreshableCredentials: false + hasRefreshableCredentials: false, + source: 'none' } } @@ -118,14 +129,14 @@ async function readFromKeychain(configDir?: string): Promise { try { const credentials = await readActiveClaudeKeychainCredentialsStrict(configDir) - return credentials ? parseOAuthCredentialsJson(credentials) : emptyOAuthCredentialReadResult() + return credentials + ? parseOAuthCredentialsJson(credentials, source) + : emptyOAuthCredentialReadResult() } catch { return emptyOAuthCredentialReadResult() } @@ -160,7 +176,7 @@ async function readFromCredentialsFile(configDir?: string): Promise { + return { + provenance: authPreparation?.provenance ?? 'system', + runtime: authPreparation?.runtime ?? 'host', + wslDistro: authPreparation?.wslDistro ?? null, + hasExplicitClaudeConfigDir: Boolean(authPreparation?.envPatch.CLAUDE_CONFIG_DIR), + credentialSource: oauthCredentials.source, + hasRefreshableCredentials: oauthCredentials.hasRefreshableCredentials + } +} + +function warnClaudeUsageFetchFailure( + authPreparation: ClaudeRuntimeAuthPreparation | undefined, + oauthCredentials: OAuthCredentialReadResult, + error: unknown +): void { + const message = error instanceof Error ? error.message : String(error) + const status = error instanceof OAuthUsageError ? error.status : null + console.warn('[claude-rate-limits] Claude usage refresh failed', { + ...buildClaudeUsageFetchDiagnostic(authPreparation, oauthCredentials), + status, + message + }) +} + // --------------------------------------------------------------------------- // OAuth API fetch // --------------------------------------------------------------------------- @@ -336,6 +380,21 @@ export async function fetchClaudeRateLimits( try { return await fetchViaOAuth(oauthCredentials.token) } catch (err) { + warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, err) + if ( + options?.authPreparation?.managedRefreshDeferredByLivePty && + err instanceof OAuthUsageError && + (err.status === 401 || err.status === 403) + ) { + return { + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: LIVE_CLAUDE_REFRESH_DEFERRED_MESSAGE, + status: 'error' + } + } if ( options?.allowPtyFallback === false || (err instanceof OAuthUsageError && err.skipPtyFallback) @@ -365,13 +424,16 @@ export async function fetchClaudeRateLimits( session: null, weekly: null, updatedAt: Date.now(), - error: 'Claude OAuth access token unavailable', + error: options?.authPreparation?.managedRefreshDeferredByLivePty + ? LIVE_CLAUDE_REFRESH_DEFERRED_MESSAGE + : 'Claude OAuth access token unavailable', status: 'error' } } try { return await fetchViaPty({ authPreparation: options?.authPreparation }) } catch (err) { + warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, err) const message = err instanceof Error ? err.message : 'Unknown error' return { provider: 'claude', @@ -513,7 +575,7 @@ export async function fetchManagedAccountUsage( // managed storage before fetching usage. This keeps inactive accounts' // single-use refresh tokens fresh so a later switch-in never materializes a // stale token. Persistence failure is non-fatal: we still try the fetch. - let token = parseOAuthCredentialsJson(credentialsJson).token + let token = parseOAuthCredentialsJson(credentialsJson, 'credentials-file').token if (location && isOauthTokenExpiring(credentialsJson)) { const refreshed = await refreshClaudeOauthCredentials(credentialsJson) if (refreshed) { @@ -523,7 +585,7 @@ export async function fetchManagedAccountUsage( // Keep going with the refreshed token in memory even if the write // failed; worst case the next poll refreshes again. } - token = parseOAuthCredentialsJson(refreshed).token + token = parseOAuthCredentialsJson(refreshed, 'credentials-file').token } } diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index 8378f68449b..c9b7eae0902 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -123,6 +123,17 @@ describe('provider usage error copy', () => { 'Network error while refreshing OAuth usage: ECONNRESET' ) }) + + it('keeps live-Claude refresh deferral copy visible', () => { + const p = provider({ + error: + 'Claude usage refresh is waiting for the live Claude terminal to rotate its credentials.' + }) + + expect(getProviderUsageErrorMessage(p)).toBe( + 'Claude usage refresh is waiting for the live Claude terminal to rotate its credentials.' + ) + }) }) describe('getWindowSections', () => {