diff --git a/src/main/rate-limits/claude-fetcher.test.ts b/src/main/rate-limits/claude-fetcher.test.ts index b68ce2cd6..e72245537 100644 --- a/src/main/rate-limits/claude-fetcher.test.ts +++ b/src/main/rate-limits/claude-fetcher.test.ts @@ -325,6 +325,102 @@ describe('fetchClaudeRateLimits', () => { expect(fetchViaPty).not.toHaveBeenCalled() }) + it('does not mask OAuth auth failures with the PTY fallback', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + } + 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 })).resolves.toMatchObject({ + provider: 'claude', + status: 'error', + error: 'Invalid OAuth token.' + }) + + 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 = { + configDir, + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + netFetchMock.mockResolvedValueOnce(new Response('temporary failure', { status: 500 })) + + await expect( + fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'error', + error: 'OAuth API returned 500' + }) + + expect(fetchViaPty).not.toHaveBeenCalled() + }) + + it('does not start the PTY fallback for refresh-only credentials when disabled', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + refreshToken: 'refresh-token', + expiresAt: Date.now() - 60_000 + } + }) + ) + + await expect( + fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'error', + error: 'Claude OAuth access token unavailable' + }) + + expect(fetchViaPty).not.toHaveBeenCalled() + }) + it('does not read inactive managed credentials from unowned auth paths', async () => { setPlatform('linux') tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index 42412c301..8ec9b9642 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -279,9 +279,14 @@ async function fetchViaOAuth(token: string): Promise { // Public API // --------------------------------------------------------------------------- -export async function fetchClaudeRateLimits(options?: { +export type FetchClaudeRateLimitsOptions = { authPreparation?: ClaudeRuntimeAuthPreparation -}): Promise { + allowPtyFallback?: boolean +} + +export async function fetchClaudeRateLimits( + options?: FetchClaudeRateLimitsOptions +): Promise { if (options?.authPreparation?.runtime === 'wsl' && !options.authPreparation.wslLinuxConfigDir) { return { provider: 'claude', @@ -299,13 +304,17 @@ export async function fetchClaudeRateLimits(options?: { try { return await fetchViaOAuth(oauthCredentials.token) } catch (err) { - if (err instanceof OAuthUsageError && err.skipPtyFallback) { + if ( + options?.allowPtyFallback === false || + (err instanceof OAuthUsageError && err.skipPtyFallback) + ) { + const message = err instanceof Error ? err.message : 'Unknown error' return { provider: 'claude', session: null, weekly: null, updatedAt: Date.now(), - error: withMacTailscaleDnsHint(err.message), + error: withMacTailscaleDnsHint(message), status: 'error' } } @@ -318,6 +327,16 @@ export async function fetchClaudeRateLimits(options?: { // whose OAuth credentials exist. This remains a fallback for older Claude // auth shapes and transient OAuth failures. if (oauthCredentials.token || oauthCredentials.hasRefreshableCredentials) { + if (options?.allowPtyFallback === false) { + return { + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Claude OAuth access token unavailable', + status: 'error' + } + } try { return await fetchViaPty({ authPreparation: options?.authPreparation }) } catch (err) { diff --git a/src/main/rate-limits/claude-oauth-usage-error.ts b/src/main/rate-limits/claude-oauth-usage-error.ts index 334ca85f5..8742ff08f 100644 --- a/src/main/rate-limits/claude-oauth-usage-error.ts +++ b/src/main/rate-limits/claude-oauth-usage-error.ts @@ -12,9 +12,9 @@ export async function createOAuthUsageError(res: Response): Promise { await service.refresh() expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ + authPreparation: undefined, + allowPtyFallback: false + }) expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1) expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(1) @@ -400,7 +404,8 @@ describe('RateLimitService', () => { wslDistro: 'Ubuntu', wslLinuxConfigDir: '/home/jin/.claude', stripAuthEnv: true - }) + }), + allowPtyFallback: false }) expect(service.getState().claudeTarget).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) }) @@ -459,6 +464,10 @@ describe('RateLimitService', () => { wslDistro: 'Ubuntu' }) + expect(fetchClaudeRateLimits).toHaveBeenLastCalledWith( + expect.objectContaining({ allowPtyFallback: false }) + ) + expect(service.getState().inactiveClaudeAccounts).not.toEqual( expect.arrayContaining([expect.objectContaining({ accountId: 'wsl-account-1' })]) ) diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 7bbbde314..dc818b4a8 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -799,7 +799,12 @@ export class RateLimitService { ? null : this.getMissingWslCodexHomeResult(codexTarget) const [claudeResult, codexResult, geminiResult, opencodeGoResult] = await Promise.allSettled([ - fetchClaudeRateLimits({ authPreparation: claudeAuthPreparation }), + fetchClaudeRateLimits({ + authPreparation: claudeAuthPreparation, + // Why: active quota refreshes run on startup/focus/timers. They must + // never spawn hidden Claude Code, which can trigger macOS App Data TCC. + allowPtyFallback: false + }), missingWslCodexHome ?? fetchCodexRateLimits({ codexHomePath, @@ -957,7 +962,12 @@ export class RateLimitService { claude: this.withFetchingStatus(previousState.claude, 'claude') }) - const claude = await fetchClaudeRateLimits({ authPreparation: claudeAuthPreparation }).catch( + const claude = await fetchClaudeRateLimits({ + authPreparation: claudeAuthPreparation, + // Why: account-change refreshes share the same automatic active quota + // surface as startup/timer refreshes, so keep them API-only as well. + allowPtyFallback: false + }).catch( (err): ProviderRateLimits => ({ provider: 'claude', session: null,