From 2063aabb55aa5731d23ed14df313bf7e44fa2f5a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:32:01 -0700 Subject: [PATCH 1/4] fix(rate-limits): stop driving a hidden Claude to read usage Claude usage fell back to a hidden interactive `claude` that typed /usage, pressed Enter every 0.8 s without reading the screen and answered "y" to anything that looked like a trust prompt. On a login picker that Enter starts a browser sign-in. It ran for system-default logins too: whenever the usage reply had no Fable window, on every poll. Every fallback whose result could not differ from the direct usage read is gone (Fable fill-in, inactive-account preview fill-in, server/network, unreadable Keychain, no credentials), together with the hidden PTY, its screen parsers and the preview Keychain staging. The one case that needs Claude itself, an expired login on an Orca-managed account with no Claude running, now starts Claude headless, never sends a message, and asks for plan usage so Claude refreshes its own login under its own lock. Orca ignores that answer, re-reads the stored login and asks the usage endpoint itself; an unchanged login backs off for 15 minutes and a CLI without get_usage is not started again until the binary changes. A managed account with no login now reports itself as signed out. --- ...de-pty-windows-pty-teardown-patch.test.mjs | 5 +- .../claude-agent-sdk-contract-pins.test.ts | 35 +- .../claude-agent-sdk-control-requests.ts | 34 ++ .../claude/claude-stream-json-connection.ts | 6 +- ...ed-session-acquisition-processless.test.ts | 1 + .../claude-structured-session-test-support.ts | 4 + .../rate-limits/claude-active-usage-fetch.ts | 104 +--- .../claude-cli-login-refresh.test.ts | 137 +++++ .../rate-limits/claude-cli-login-refresh.ts | 103 ++++ .../rate-limits/claude-cli-usage-fetch.ts | 117 ---- .../claude-fetcher-cli-fallback.test.ts | 490 ---------------- .../claude-fetcher-cli-login-refresh.test.ts | 328 +++++++++++ .../claude-fetcher-fable-usage.test.ts | 135 +---- ...laude-fetcher-keychain-credentials.test.ts | 34 +- ...aude-fetcher-managed-account-usage.test.ts | 275 +-------- .../claude-fetcher-test-harness.ts | 15 +- .../claude-managed-account-credentials.ts | 32 - .../claude-managed-account-usage.ts | 47 +- .../rate-limits/claude-managed-usage-panel.ts | 113 ---- src/main/rate-limits/claude-oauth-recovery.ts | 133 +++-- .../rate-limits/claude-pty-reset-parser.ts | 307 ---------- .../rate-limits/claude-pty-stop-markers.ts | 14 - .../rate-limits/claude-pty-usage-parser.ts | 166 ------ src/main/rate-limits/claude-pty.test.ts | 554 ------------------ src/main/rate-limits/claude-pty.ts | 354 ----------- .../claude-usage-error-classification.test.ts | 7 +- .../claude-usage-error-classification.ts | 16 +- .../rate-limits/claude-usage-fetch-options.ts | 5 +- .../claude-usage-refresh-plan.test.ts | 32 - .../rate-limits/claude-usage-refresh-plan.ts | 47 -- src/main/rate-limits/claude-usage-result.ts | 37 +- .../rate-limits/hidden-pty-cleanup.test.ts | 135 ----- src/main/rate-limits/hidden-pty-cleanup.ts | 58 -- .../service-account-target-selection.test.ts | 20 +- .../service-inactive-account-previews.test.ts | 17 - .../service-refresh-orchestration.test.ts | 3 +- .../service/service-fetch-targets.ts | 11 +- .../service/service-full-cycle-preparation.ts | 3 +- .../service/service-inactive-accounts.ts | 1 - .../service/service-provider-cycles.ts | 3 +- ...structured-fake-connection-test-fixture.ts | 4 + ...ed-claude-scripted-runtime-test-support.ts | 1 + .../__fixtures__/wsl-invocation-allowlist.txt | 1 - 43 files changed, 851 insertions(+), 3093 deletions(-) create mode 100644 src/main/rate-limits/claude-cli-login-refresh.test.ts create mode 100644 src/main/rate-limits/claude-cli-login-refresh.ts delete mode 100644 src/main/rate-limits/claude-cli-usage-fetch.ts delete mode 100644 src/main/rate-limits/claude-fetcher-cli-fallback.test.ts create mode 100644 src/main/rate-limits/claude-fetcher-cli-login-refresh.test.ts delete mode 100644 src/main/rate-limits/claude-managed-usage-panel.ts delete mode 100644 src/main/rate-limits/claude-pty-reset-parser.ts delete mode 100644 src/main/rate-limits/claude-pty-stop-markers.ts delete mode 100644 src/main/rate-limits/claude-pty-usage-parser.ts delete mode 100644 src/main/rate-limits/claude-pty.test.ts delete mode 100644 src/main/rate-limits/claude-pty.ts delete mode 100644 src/main/rate-limits/claude-usage-refresh-plan.test.ts delete mode 100644 src/main/rate-limits/claude-usage-refresh-plan.ts delete mode 100644 src/main/rate-limits/hidden-pty-cleanup.test.ts delete mode 100644 src/main/rate-limits/hidden-pty-cleanup.ts diff --git a/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs b/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs index d9ee133a70e3..3942f13d3263 100644 --- a/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs +++ b/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs @@ -9,9 +9,8 @@ // // Those numbers are the `!useConptyDll` branch, which is the branch a RELAY runs. Every desktop // site that opens a terminal pane sets `useConptyDll: true` and takes the other branch, where -// upstream already destroys the input socket. The hidden rate-limit probe -// (`src/main/rate-limits/claude-pty.ts`) does omit the option and so does run this hunk, but no -// user-visible pane does. The divergence pinned below is about which +// upstream already destroys the input socket; no desktop site runs this hunk. The divergence +// pinned below is about which // branch each host runs for terminals -- not about a regression in the panes users open. import { createRequire } from 'node:module' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' diff --git a/src/main/claude/claude-agent-sdk-contract-pins.test.ts b/src/main/claude/claude-agent-sdk-contract-pins.test.ts index dcd331d70a4f..6ffb988ad8fd 100644 --- a/src/main/claude/claude-agent-sdk-contract-pins.test.ts +++ b/src/main/claude/claude-agent-sdk-contract-pins.test.ts @@ -16,7 +16,10 @@ import { spawnProcess } from '../../shared/child-process/run-process' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' -import { claudeQuerySettingsReader } from './claude-agent-sdk-control-requests' +import { + claudeQueryControlRequester, + claudeQuerySettingsReader +} from './claude-agent-sdk-control-requests' import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution' // Contract pins for @anthropic-ai/claude-agent-sdk, run against the real SDK @@ -405,6 +408,36 @@ describe('Claude Agent SDK contract pins', () => { } }) + it('still exposes the runtime control request the usage login refresh sends get_usage through', async () => { + // 0.3.251's typed get_usage wrapper cannot send skip_behaviors, so the refresh calls the + // undeclared Query.request; this pin fails if a bump drops or reshapes it. + const usage = { rate_limits_available: true, rate_limits: { five_hour: null } } + const scenario = scriptScenario([{ delayMs: 3_000 }], { get_usage: usage }) + const session = query({ + prompt: singleUserTurn(), + options: { + pathToClaudeCodeExecutable: FAKE_CLI, + cwd: scenario.cwd, + env: scenarioEnv(scenario) + } + }) + try { + const request = claudeQueryControlRequester(session) + expect(request, 'the SDK no longer exposes Query.request at runtime').not.toBeNull() + await expect( + request?.({ subtype: 'get_usage', skip_behaviors: true }).then( + (envelope) => envelope.response + ) + ).resolves.toEqual(usage) + } finally { + await session.return(undefined) + } + expect(scenario.readReport().controlRequests.map((frame) => frame.request)).toContainEqual({ + subtype: 'get_usage', + skip_behaviors: true + }) + }) + it('maps resume identity to --resume and --resume-session-at', async () => { const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }]) const spawns: SpawnSeen[] = [] diff --git a/src/main/claude/claude-agent-sdk-control-requests.ts b/src/main/claude/claude-agent-sdk-control-requests.ts index d33131471021..fc5ad8996e46 100644 --- a/src/main/claude/claude-agent-sdk-control-requests.ts +++ b/src/main/claude/claude-agent-sdk-control-requests.ts @@ -50,6 +50,23 @@ export function claudeQueryAsyncCanceller( return typeof cancel === 'function' ? cancel.bind(query) : null } +/** + * Query.request is the runtime method every typed control call goes through; 0.3.251 omits it + * from the declaration, and its typed get_usage wrapper cannot send skip_behaviors. The typeof + * guard is its degradation path. + */ +type ClaudeQueryControlRequester = { + request?: (request: Record) => Promise<{ response?: unknown }> +} + +export function claudeQueryControlRequester( + query: Query +): ((request: Record) => Promise<{ response?: unknown }>) | null { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only reads an optional method and checks its type before calling it. + const request = (query as unknown as ClaudeQueryControlRequester).request + return typeof request === 'function' ? request.bind(query) : null +} + export type ClaudeControlOptions = { timeoutMs?: number } /** @@ -109,6 +126,8 @@ export type ClaudeControlSurface = { getSettings: (options?: ClaudeControlOptions) => Promise /** The `/context` breakdown; older CLIs reject the request and the caller shows nothing. */ getContextUsage: (options?: ClaudeControlOptions) => Promise + /** Plan usage (`/usage`) without the local transcript scan; older CLIs reject the subtype. */ + getUsage: (options?: ClaudeControlOptions) => Promise } type InterruptingQuery = { @@ -160,6 +179,21 @@ export function createClaudeControlSurface(query: Query): ClaudeControlSurface { runClaudeControl('initialize', () => query.initializationResult(), null), getContextUsage: (options) => runClaudeControl('get_context_usage', () => query.getContextUsage(), options?.timeoutMs), + getUsage: (options) => { + const request = claudeQueryControlRequester(query) + return request + ? runClaudeControl( + 'get_usage', + () => + request({ subtype: 'get_usage', skip_behaviors: true }).then( + (envelope) => envelope?.response + ), + options?.timeoutMs + ) + : Promise.reject( + new ClaudeControlRequestError('get_usage', 'this SDK exposes no control request') + ) + }, getSettings: (options) => { const read = claudeQuerySettingsReader(query) return read diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts index 6f67e04b36ea..ce29abc203cb 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -44,10 +44,14 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } +/** Sessions use the structured options; a session-less probe adds the isolation ones. */ +export type ClaudeStreamJsonSdkOptions = ClaudeStructuredSdkOptions & + Pick + export type ClaudeStreamJsonLaunch = { /** Orca's resolved user CLI; the SDK falls back to a bundled binary that is not installed. */ pathToClaudeCodeExecutable: string - options: ClaudeStructuredSdkOptions + options: ClaudeStreamJsonSdkOptions cwd: string env?: Record } diff --git a/src/main/claude/claude-structured-session-acquisition-processless.test.ts b/src/main/claude/claude-structured-session-acquisition-processless.test.ts index eb4babdb9aca..1f220dd4ec76 100644 --- a/src/main/claude/claude-structured-session-acquisition-processless.test.ts +++ b/src/main/claude/claude-structured-session-acquisition-processless.test.ts @@ -35,6 +35,7 @@ describe('Claude structured processless acquisition', () => { }, getSettings: async () => ({}), getContextUsage: async () => ({}), + getUsage: async () => ({}), supportedModels: async () => [], interrupt: async () => undefined, cancelAsyncMessage: async () => false, diff --git a/src/main/claude/claude-structured-session-test-support.ts b/src/main/claude/claude-structured-session-test-support.ts index dd7ad1e8d8c5..cc2a0044155e 100644 --- a/src/main/claude/claude-structured-session-test-support.ts +++ b/src/main/claude/claude-structured-session-test-support.ts @@ -152,6 +152,10 @@ export function fakeClaude( ...(options.initAccount === undefined ? {} : { account: options.initAccount }) } }, + getUsage: async () => { + connection.calls.push({ subtype: 'get_usage' }) + return {} + }, getContextUsage: async () => { connection.calls.push({ subtype: 'get_context_usage' }) return options.contextUsage ?? {} diff --git a/src/main/rate-limits/claude-active-usage-fetch.ts b/src/main/rate-limits/claude-active-usage-fetch.ts index f285ba13ce93..b132f2011346 100644 --- a/src/main/rate-limits/claude-active-usage-fetch.ts +++ b/src/main/rate-limits/claude-active-usage-fetch.ts @@ -1,6 +1,4 @@ import type { ProviderRateLimits } from '../../shared/rate-limit-types' -import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' -import { completeClaudeOAuthUsageSuccess, fetchClaudeUsageViaCli } from './claude-cli-usage-fetch' import { readClaudeOAuthCredentials, resolveClaudeOAuthCredentialReadOptions @@ -19,9 +17,10 @@ import { classifyClaudeOAuthUsageError } from './claude-usage-error-classification' import type { ClaudeRateLimitFetchOptions } from './claude-usage-fetch-options' -import { resolveClaudeUsageRefreshPlan } from './claude-usage-refresh-plan' import { abortedClaudeRateLimitResult, + claudeOAuthUsageSuccess, + isManagedClaudeAuth, makeClaudeUsageResult, metadataForClaudeUsageAttempt, recordClaudeUsageAttempt, @@ -35,11 +34,7 @@ export async function fetchActiveClaudeRateLimits( return abortedClaudeRateLimitResult() } const attempts = { attemptedSources: [] } - const allowCliFallback = options?.allowPtyFallback !== false - const plan = resolveClaudeUsageRefreshPlan({ - authPreparation: options?.authPreparation, - allowCliFallback - }) + const allowCliLoginRefresh = options?.allowCliLoginRefresh === true if (options?.authPreparation?.runtime === 'wsl' && !options.authPreparation.wslLinuxConfigDir) { return makeClaudeUsageResult( @@ -60,18 +55,18 @@ export async function fetchActiveClaudeRateLimits( return abortedClaudeRateLimitResult() } - if (plan.steps.some((step) => step.source === 'oauth') && oauthCredentials.token) { + if (oauthCredentials.token) { recordClaudeUsageAttempt(attempts, 'oauth') try { - const oauthLimits = await fetchClaudeOAuthUsage(oauthCredentials.token, options?.signal) + const limits = await fetchClaudeOAuthUsage(oauthCredentials.token, options?.signal) if (options?.signal?.aborted) { return abortedClaudeRateLimitResult() } - return await completeClaudeOAuthUsageSuccess({ - oauthLimits, + return claudeOAuthUsageSuccess({ + limits, oauthCredentials, attempts, - options + authPreparation: options?.authPreparation }) } catch (error) { warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, error) @@ -102,7 +97,7 @@ export async function fetchActiveClaudeRateLimits( }) } - if (classification.shouldAttemptDelegatedRefresh && allowCliFallback) { + if (classification.shouldAttemptDelegatedRefresh && allowCliLoginRefresh) { const repaired = await repairClaudeCredentialsThenRetryOAuth({ options, attempts, @@ -113,20 +108,6 @@ export async function fetchActiveClaudeRateLimits( } } - if (classification.shouldAttemptCliFallback && allowCliFallback) { - try { - return await fetchClaudeUsageViaCli({ - authPreparation: options?.authPreparation, - oauthCredentials, - attempts, - networkProxySettings: options?.networkProxySettings, - signal: options?.signal - }) - } catch (ptyError) { - warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, ptyError) - } - } - return makeClaudeUsageClassificationError({ error, classification, @@ -154,7 +135,7 @@ export async function fetchActiveClaudeRateLimits( if ( oauthCredentials.hasRefreshableCredentials && credentialClassification.shouldAttemptDelegatedRefresh && - allowCliFallback + allowCliLoginRefresh ) { const repaired = await repairClaudeCredentialsThenRetryOAuth({ options, @@ -166,41 +147,6 @@ export async function fetchActiveClaudeRateLimits( } } - if ( - (oauthCredentials.token || - oauthCredentials.hasRefreshableCredentials || - oauthCredentials.keychainUnavailable) && - credentialClassification.shouldAttemptCliFallback && - allowCliFallback - ) { - try { - return await fetchClaudeUsageViaCli({ - authPreparation: options?.authPreparation, - oauthCredentials, - attempts, - networkProxySettings: options?.networkProxySettings, - signal: options?.signal - }) - } catch (error) { - warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, error) - return makeClaudeUsageResult( - 'error', - withMacTailscaleDnsHint(error instanceof Error ? error.message : 'Unknown error'), - { - ...metadataForClaudeUsageAttempt({ - attemptedSources: attempts.attemptedSources, - oauthCredentials, - authPreparation: options?.authPreparation, - failureKind: - credentialClassification.failureKind === 'keychain-unavailable' - ? 'keychain-unavailable' - : 'cli-unavailable' - }) - } - ) - } - } - if (oauthCredentials.keychainUnavailable) { return makeClaudeUsageResult('error', 'Claude Keychain credentials unavailable', { ...metadataForClaudeUsageAttempt({ @@ -223,26 +169,14 @@ export async function fetchActiveClaudeRateLimits( }) } - if (allowCliFallback && plan.steps.some((step) => step.source === 'cli')) { - try { - return await fetchClaudeUsageViaCli({ - authPreparation: options?.authPreparation, - oauthCredentials, - attempts, - networkProxySettings: options?.networkProxySettings, - signal: options?.signal - }) - } catch (error) { - warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, error) - } - } - - return makeClaudeUsageResult('unavailable', 'No subscription plan — API key billing', { - ...metadataForClaudeUsageAttempt({ - attemptedSources: attempts.attemptedSources, - oauthCredentials, - authPreparation: options?.authPreparation, - failureKind: 'missing-credentials' - }) + const metadata = metadataForClaudeUsageAttempt({ + attemptedSources: attempts.attemptedSources, + oauthCredentials, + authPreparation: options?.authPreparation, + failureKind: 'missing-credentials' }) + // A managed account with no login is signed out; without one, the user may bill by API key. + return isManagedClaudeAuth(options?.authPreparation) + ? makeClaudeUsageResult('error', 'Claude account is signed out', metadata) + : makeClaudeUsageResult('unavailable', 'No subscription plan — API key billing', metadata) } diff --git a/src/main/rate-limits/claude-cli-login-refresh.test.ts b/src/main/rate-limits/claude-cli-login-refresh.test.ts new file mode 100644 index 000000000000..26aeb8768ad4 --- /dev/null +++ b/src/main/rate-limits/claude-cli-login-refresh.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' +import type { + ClaudeStreamJsonConnection, + ClaudeStreamJsonConnectionHandlers, + ClaudeStreamJsonLaunch +} from '../claude/claude-stream-json-connection' +import { refreshClaudeLoginViaCli } from './claude-cli-login-refresh' + +vi.mock('./hidden-rate-limit-pty-cwd', () => ({ + resolveHiddenRateLimitPtyCwd: () => '/tmp/orca-test/rate-limit-pty-cwd' +})) + +const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir: '/Users/test/managed/account-1', + runtime: 'host', + wslDistro: null, + wslLinuxConfigDir: null, + envPatch: { CLAUDE_CONFIG_DIR: '/Users/test/managed/account-1' }, + stripAuthEnv: true, + provenance: 'managed:account-1' +} + +function fakeConnect(getUsage: (handlers: ClaudeStreamJsonConnectionHandlers) => Promise) { + const seen: { launch?: ClaudeStreamJsonLaunch; closes: number; usageTimeout?: number } = { + closes: 0 + } + const connect = vi.fn( + async (launch: ClaudeStreamJsonLaunch, handlers: ClaudeStreamJsonConnectionHandlers = {}) => { + seen.launch = launch + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the refresh only calls getUsage and close. + return { + getUsage: (options?: { timeoutMs?: number }) => { + seen.usageTimeout = options?.timeoutMs + return getUsage(handlers) + }, + close: async () => { + seen.closes += 1 + return true + } + } as unknown as ClaudeStreamJsonConnection + } + ) + return { connect, seen } +} + +describe('refreshClaudeLoginViaCli', () => { + it('asks the account’s own Claude for usage in an isolated session-less child, then closes it', async () => { + vi.stubEnv('ANTHROPIC_API_KEY', 'ambient-key') + const { connect, seen } = fakeConnect(async () => ({ rate_limits_available: true })) + + await expect( + refreshClaudeLoginViaCli({ + authPreparation, + networkProxySettings: { httpProxyUrl: 'http://proxy.test:8080' }, + connect, + resolveCommand: () => '/fake/bin/claude' + }) + ).resolves.toEqual({ kind: 'answered' }) + + expect(seen.launch).toMatchObject({ + pathToClaudeCodeExecutable: '/fake/bin/claude', + cwd: '/tmp/orca-test/rate-limit-pty-cwd', + options: { + settingSources: ['user'], + persistSession: false, + strictMcpConfig: true, + mcpServers: {}, + settings: { disableAllHooks: true } + } + }) + expect(seen.launch?.env).toMatchObject({ + CLAUDE_CONFIG_DIR: '/Users/test/managed/account-1', + ENABLE_CLAUDEAI_MCP_SERVERS: 'false' + }) + expect(seen.launch?.env?.ANTHROPIC_API_KEY).toBeUndefined() + expect(Object.values(seen.launch?.env ?? {})).toContain('http://proxy.test:8080') + expect(seen.usageTimeout).toBeGreaterThan(0) + expect(seen.closes).toBe(1) + vi.unstubAllEnvs() + }) + + it('reports a CLI without get_usage as unsupported', async () => { + const { connect, seen } = fakeConnect(async () => { + throw new Error('Unsupported control request subtype: get_usage') + }) + + await expect( + refreshClaudeLoginViaCli({ authPreparation, connect, resolveCommand: () => '/fake/claude' }) + ).resolves.toMatchObject({ kind: 'unsupported' }) + expect(seen.closes).toBe(1) + }) + + it('reports why the CLI exited before it answered', async () => { + const { connect, seen } = fakeConnect(async (handlers) => { + handlers.onFault?.(new Error('claude stream-json exited (code 1): accept the updated terms')) + throw new Error('Query closed before response received') + }) + + await expect( + refreshClaudeLoginViaCli({ authPreparation, connect, resolveCommand: () => '/fake/claude' }) + ).resolves.toEqual({ + kind: 'failed', + message: 'claude stream-json exited (code 1): accept the updated terms' + }) + expect(seen.closes).toBe(1) + }) + + it('closes the child when the fetch is aborted mid-request', async () => { + const controller = new AbortController() + const { connect, seen } = fakeConnect(async () => { + controller.abort() + throw new Error('Query closed before response received') + }) + + await expect( + refreshClaudeLoginViaCli({ + authPreparation, + connect, + signal: controller.signal, + resolveCommand: () => '/fake/claude' + }) + ).resolves.toMatchObject({ kind: 'failed' }) + expect(seen.closes).toBeGreaterThanOrEqual(1) + }) + + it('starts nothing when already aborted', async () => { + const controller = new AbortController() + controller.abort() + const { connect } = fakeConnect(async () => ({})) + + await expect( + refreshClaudeLoginViaCli({ authPreparation, connect, signal: controller.signal }) + ).resolves.toMatchObject({ kind: 'failed' }) + expect(connect).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/rate-limits/claude-cli-login-refresh.ts b/src/main/rate-limits/claude-cli-login-refresh.ts new file mode 100644 index 000000000000..0f3252afb3b6 --- /dev/null +++ b/src/main/rate-limits/claude-cli-login-refresh.ts @@ -0,0 +1,103 @@ +import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy' +import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' +import { applyClaudeEnvPatch } from '../claude-accounts/environment' +import { whenClaudeAuthSwitchSettles } from '../claude-accounts/live-pty-gate' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' +import { withoutInheritedClaudeConfigDir } from '../claude/claude-config-dir-pin' +import { + openClaudeStreamJsonConnection, + type ClaudeStreamJsonConnection, + type ClaudeStreamJsonLaunch +} from '../claude/claude-stream-json-connection' +import { resolveClaudeCommand } from '../codex-cli/command' +import { resolveHiddenRateLimitPtyCwd } from './hidden-rate-limit-pty-cwd' + +const USAGE_REQUEST_TIMEOUT_MS = 20_000 + +export type ClaudeCliLoginRefreshOutcome = + | { kind: 'answered' } + /** The installed CLI has no `get_usage`; retrying the same binary cannot change that. */ + | { kind: 'unsupported'; message: string } + | { kind: 'failed'; message: string } + +type ConnectClaude = typeof openClaudeStreamJsonConnection + +function isUnsupportedSubtype(message: string): boolean { + return /unsupported control request|no control request/i.test(message) +} + +/** + * Asks the account's own Claude CLI for plan usage so it refreshes its login under its own + * refresh lock and saves it to its own store. The answer itself is discarded: the CLI may serve + * a cached reading and reports transport failures as an empty one, so the caller re-reads the + * stored credentials and asks the usage endpoint itself. + */ +export async function refreshClaudeLoginViaCli(input: { + authPreparation: ClaudeRuntimeAuthPreparation + networkProxySettings?: NetworkProxySettings + signal?: AbortSignal + connect?: ConnectClaude + resolveCommand?: () => string +}): Promise { + if (input.signal?.aborted) { + return { kind: 'failed', message: 'aborted' } + } + // A switch re-materializes the runtime credentials this child would refresh. + if (!(await whenClaudeAuthSwitchSettles())) { + return { kind: 'failed', message: 'a Claude account switch is in progress' } + } + const command = (input.resolveCommand ?? resolveClaudeCommand)() + const launch: ClaudeStreamJsonLaunch = { + pathToClaudeCodeExecutable: command, + // No user message is ever sent, so nothing reaches the model; these keep the session-less + // child from running hooks, MCP servers or project settings, or saving a transcript. + options: { + settingSources: ['user'], + persistSession: false, + strictMcpConfig: true, + mcpServers: {}, + settings: { disableAllHooks: true } + }, + // Untrusted folders are trusted in headless mode, so the child gets an empty Orca-owned one. + cwd: resolveHiddenRateLimitPtyCwd(), + env: withCliRuntimeOnPath(command, { + ...applyClaudeEnvPatch( + withoutInheritedClaudeConfigDir(process.env), + input.authPreparation.envPatch, + { stripAuthEnv: input.authPreparation.stripAuthEnv } + ), + // The CLI spawned directly would otherwise reach Anthropic outside the user's proxy. + ...buildConfiguredProxyEnv(input.networkProxySettings), + ENABLE_CLAUDEAI_MCP_SERVERS: 'false' + }) + } + + const fault: { first: Error | null } = { first: null } + let connection: ClaudeStreamJsonConnection | null = null + const closeOnAbort = (): void => { + void connection?.close() + } + input.signal?.addEventListener('abort', closeOnAbort, { once: true }) + try { + connection = await (input.connect ?? openClaudeStreamJsonConnection)(launch, { + onFault: (error) => { + fault.first ??= error + } + }) + if (input.signal?.aborted) { + return { kind: 'failed', message: 'aborted' } + } + await connection.getUsage({ timeoutMs: USAGE_REQUEST_TIMEOUT_MS }) + return { kind: 'answered' } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (isUnsupportedSubtype(message)) { + return { kind: 'unsupported', message } + } + // An exit before the reply (e.g. unaccepted terms) closes the query; the stderr tail is the reason. + return { kind: 'failed', message: fault.first?.message ?? message } + } finally { + input.signal?.removeEventListener('abort', closeOnAbort) + await connection?.close() + } +} diff --git a/src/main/rate-limits/claude-cli-usage-fetch.ts b/src/main/rate-limits/claude-cli-usage-fetch.ts deleted file mode 100644 index 70d264a4e68f..000000000000 --- a/src/main/rate-limits/claude-cli-usage-fetch.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { ProviderRateLimits, UsageRateLimitFailureKind } from '../../shared/rate-limit-types' -import type { NetworkProxySettings } from '../../shared/network-proxy' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' -import { fetchViaPty } from './claude-pty' -import type { ClaudeOAuthCredentialReadResult } from './claude-oauth-credentials' -import type { ClaudeRateLimitFetchOptions } from './claude-usage-fetch-options' -import { - abortedClaudeRateLimitResult, - canSupplementClaudeOAuthUsage, - isManagedClaudeAuth, - mergeClaudeUsageWindows, - metadataForClaudeUsageAttempt, - recordClaudeUsageAttempt, - type ClaudeUsageAttemptState, - warnClaudeUsageFetchFailure, - withClaudeUsageMetadata -} from './claude-usage-result' - -function classifyClaudeCliUsageFailure( - limits: ProviderRateLimits -): UsageRateLimitFailureKind | undefined { - if (!limits.error) { - return undefined - } - if (/rate limited/i.test(limits.error)) { - return 'rate-limited' - } - if (/plan usage is unavailable|usage is unavailable/i.test(limits.error)) { - return 'usage-unavailable' - } - return 'cli-unavailable' -} - -export async function fetchClaudeUsageViaCli(input: { - authPreparation?: ClaudeRuntimeAuthPreparation - oauthCredentials: ClaudeOAuthCredentialReadResult - attempts: ClaudeUsageAttemptState - networkProxySettings?: NetworkProxySettings - signal?: AbortSignal -}): Promise { - recordClaudeUsageAttempt(input.attempts, 'cli') - const limits = await fetchViaPty({ - authPreparation: input.authPreparation, - networkProxySettings: input.networkProxySettings, - signal: input.signal - }) - return withClaudeUsageMetadata( - limits, - metadataForClaudeUsageAttempt({ - attemptedSources: input.attempts.attemptedSources, - oauthCredentials: input.oauthCredentials, - authPreparation: input.authPreparation, - source: 'cli', - failureKind: classifyClaudeCliUsageFailure(limits) - }) - ) -} - -async function supplementOAuthUsageFromCli(input: { - oauthLimits: ProviderRateLimits - authPreparation?: ClaudeRuntimeAuthPreparation - oauthCredentials: ClaudeOAuthCredentialReadResult - attempts: ClaudeUsageAttemptState - allowUsagePanelSupplement: boolean - networkProxySettings?: NetworkProxySettings - signal?: AbortSignal -}): Promise { - if (input.signal?.aborted || !canSupplementClaudeOAuthUsage(input)) { - return input.oauthLimits - } - try { - return mergeClaudeUsageWindows( - input.oauthLimits, - await fetchClaudeUsageViaCli({ - authPreparation: input.authPreparation, - oauthCredentials: input.oauthCredentials, - attempts: input.attempts, - networkProxySettings: input.networkProxySettings, - signal: input.signal - }) - ) - } catch (error) { - warnClaudeUsageFetchFailure(input.authPreparation, input.oauthCredentials, error) - return input.oauthLimits - } -} - -export async function completeClaudeOAuthUsageSuccess(input: { - oauthLimits: ProviderRateLimits - oauthCredentials: ClaudeOAuthCredentialReadResult - attempts: ClaudeUsageAttemptState - options?: ClaudeRateLimitFetchOptions -}): 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, - metadataForClaudeUsageAttempt({ - attemptedSources: input.attempts.attemptedSources, - oauthCredentials: input.oauthCredentials, - authPreparation: input.options?.authPreparation, - source: 'oauth' - }) - ) -} diff --git a/src/main/rate-limits/claude-fetcher-cli-fallback.test.ts b/src/main/rate-limits/claude-fetcher-cli-fallback.test.ts deleted file mode 100644 index 428a29106941..000000000000 --- a/src/main/rate-limits/claude-fetcher-cli-fallback.test.ts +++ /dev/null @@ -1,490 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { fetchClaudeRateLimits } from './claude-fetcher' -import { primeClaudeFetcherMocks, restorePlatform } from './claude-fetcher-test-harness' -import { fetchViaPty } from './claude-pty' -import { - readActiveClaudeKeychainCredentials, - readActiveClaudeKeychainCredentialsStrict -} from '../claude-accounts/keychain' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' - -const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( - () => ({ - netFetchMock: vi.fn(), - readFileMock: vi.fn(), - resolveProxyMock: vi.fn(), - setProxyMock: vi.fn(), - appGetPathMock: vi.fn() - }) -) - -vi.mock('node:fs/promises', () => ({ - readFile: readFileMock -})) - -vi.mock('electron', () => ({ - app: { - getPath: appGetPathMock - }, - net: { - fetch: netFetchMock - }, - session: { - defaultSession: { - resolveProxy: resolveProxyMock, - setProxy: setProxyMock - } - } -})) - -vi.mock('./claude-pty', () => ({ - fetchViaPty: vi.fn() -})) - -vi.mock('../claude-accounts/keychain', () => ({ - deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), - readActiveClaudeKeychainCredentials: vi.fn(), - readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn(), - writeActiveClaudeKeychainCredentials: vi.fn(), - writeManagedClaudeKeychainCredentials: vi.fn() -})) - -describe('fetchClaudeRateLimits', () => { - beforeEach(() => { - primeClaudeFetcherMocks({ - netFetchMock, - readFileMock, - resolveProxyMock, - setProxyMock, - appGetPathMock - }) - }) - - afterEach(() => { - restorePlatform() - }) - - it('does not mask OAuth usage rate limits with the PTY fallback', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'expired-oauth-token', - refreshToken: 'refresh-token', - expiresAt: Date.now() - 60_000 - } - }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - error: { - type: 'rate_limit_error', - message: 'Rate limited. Please try again later.' - } - }), - { status: 429, headers: { 'retry-after': '3000' } } - ) - ) - - const before = Date.now() - const result = await fetchClaudeRateLimits({ authPreparation }) - expect(result).toMatchObject({ - provider: 'claude', - status: 'error', - error: 'Claude usage is rate limited right now.', - usageMetadata: expect.objectContaining({ failureKind: 'rate-limited' }) - }) - expect(result.usageMetadata?.retryAtMs).toBeGreaterThanOrEqual(before + 3000 * 1000) - expect(result.usageMetadata?.retryAtMs).toBeLessThanOrEqual(Date.now() + 3000 * 1000) - - expect(netFetchMock).toHaveBeenCalledWith( - 'https://api.anthropic.com/api/oauth/usage', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer expired-oauth-token' - }) - }) - ) - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('omits retryAtMs when a 429 has no Retry-After header', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'expired-oauth-token', - refreshToken: 'refresh-token', - expiresAt: Date.now() - 60_000 - } - }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - error: { - type: 'rate_limit_error', - message: 'Rate limited. Please try again later.' - } - }), - { status: 429 } - ) - ) - - const result = await fetchClaudeRateLimits({ authPreparation }) - expect(result.status).toBe('error') - expect(result.usageMetadata?.retryAtMs).toBeUndefined() - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('uses CLI fallback for OAuth auth failures when automatic repair is safe', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - 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: 'ok', - session: { usedPercent: 56 }, - usageMetadata: { - source: 'cli', - attemptedSources: ['oauth', 'cli'] - } - }) - - expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) - }) - - it('re-reads credentials and retries OAuth once after CLI repair', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict) - .mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'stale-oauth-token', - refreshToken: 'refresh-token', - expiresAt: Date.now() - 60_000 - } - }) - ) - // Legacy item absent — the stale-scoped legacy fallback must not preempt - // CLI repair in this scenario. - .mockResolvedValueOnce(null) - .mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'repaired-oauth-token', - refreshToken: 'refresh-token-2', - expiresAt: Date.now() + 60_000 - } - }) - ) - netFetchMock - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - error: { - type: 'authentication_error', - message: 'Invalid OAuth token.' - } - }), - { status: 401 } - ) - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - five_hour: { utilization: 14 }, - seven_day: { utilization: 27 } - }), - { status: 200 } - ) - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 14 }, - weekly: { usedPercent: 27 }, - usageMetadata: { - source: 'oauth', - attemptedSources: ['oauth', 'cli'], - credentialSource: 'scoped-keychain' - } - }) - - expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) - expect(netFetchMock).toHaveBeenNthCalledWith( - 2, - 'https://api.anthropic.com/api/oauth/usage', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer repaired-oauth-token' - }) - }) - ) - }) - - 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 CLI fallback when live Claude owns managed refresh and no token is readable', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - managedRefreshDeferredByLivePty: true, - provenance: 'managed:account-1' - } - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - error: - 'Claude usage refresh is waiting for the live Claude terminal to rotate its credentials.', - usageMetadata: { - failureKind: 'deferred-by-live-session', - deferredByLiveClaudeSession: true, - attemptedSources: [] - } - }) - - 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: { CLAUDE_CONFIG_DIR: configDir }, - 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: { CLAUDE_CONFIG_DIR: configDir }, - 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('falls back to CLI when OAuth credentials are missing in automatic mode', async () => { - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir: '/Users/test/.claude', - envPatch: {}, - stripAuthEnv: false, - provenance: 'system' - } - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 56 }, - usageMetadata: { - source: 'cli', - attemptedSources: ['cli'], - credentialSource: 'none' - } - }) - - expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) - }) - - it('marks CLI plan usage shell results as usage unavailable', async () => { - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir: '/Users/test/.claude', - envPatch: {}, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(fetchViaPty).mockResolvedValueOnce({ - provider: 'claude', - session: null, - weekly: null, - updatedAt: 1, - error: 'Claude plan usage is unavailable for this Claude CLI session.', - status: 'error' - }) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - error: 'Claude plan usage is unavailable for this Claude CLI session.', - usageMetadata: { - source: 'cli', - attemptedSources: ['cli'], - failureKind: 'usage-unavailable' - } - }) - }) - - it('surfaces Keychain read failures as structured usage metadata when CLI fallback is disabled', async () => { - vi.mocked(readActiveClaudeKeychainCredentials).mockRejectedValueOnce( - new Error('security timed out after 3000ms') - ) - - await expect(fetchClaudeRateLimits({ allowPtyFallback: false })).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - error: 'Claude Keychain credentials unavailable', - usageMetadata: { - failureKind: 'keychain-unavailable', - attemptedSources: [], - credentialSource: 'none' - } - }) - - expect(fetchViaPty).not.toHaveBeenCalled() - }) - - it('uses CLI fallback when Keychain is unavailable in automatic mode', async () => { - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir: '/Users/test/.claude', - envPatch: {}, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentials).mockRejectedValueOnce( - new Error('security timed out after 3000ms') - ) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 56 }, - usageMetadata: { - source: 'cli', - attemptedSources: ['cli'], - credentialSource: 'none' - } - }) - - expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) - }) -}) diff --git a/src/main/rate-limits/claude-fetcher-cli-login-refresh.test.ts b/src/main/rate-limits/claude-fetcher-cli-login-refresh.test.ts new file mode 100644 index 000000000000..df593edbd6ad --- /dev/null +++ b/src/main/rate-limits/claude-fetcher-cli-login-refresh.test.ts @@ -0,0 +1,328 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { fetchClaudeRateLimits } from './claude-fetcher' +import { primeClaudeFetcherMocks, restorePlatform } from './claude-fetcher-test-harness' +import { refreshClaudeLoginViaCli } from './claude-cli-login-refresh' +import { + readActiveClaudeKeychainCredentials, + readActiveClaudeKeychainCredentialsStrict +} from '../claude-accounts/keychain' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' +import type * as CodexCliCommand from '../codex-cli/command' + +const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( + () => ({ + netFetchMock: vi.fn(), + readFileMock: vi.fn(), + resolveProxyMock: vi.fn(), + setProxyMock: vi.fn(), + appGetPathMock: vi.fn() + }) +) + +vi.mock('node:fs/promises', () => ({ + readFile: readFileMock +})) + +vi.mock('electron', () => ({ + app: { + getPath: appGetPathMock + }, + net: { + fetch: netFetchMock + }, + session: { + defaultSession: { + resolveProxy: resolveProxyMock, + setProxy: setProxyMock + } + } +})) + +vi.mock('./claude-cli-login-refresh', () => ({ + refreshClaudeLoginViaCli: vi.fn() +})) + +vi.mock('../codex-cli/command', async (importOriginal) => ({ + ...(await importOriginal()), + resolveClaudeCommand: () => '/fake/bin/claude' +})) + +vi.mock('../claude-accounts/keychain', () => ({ + deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), + readActiveClaudeKeychainCredentials: vi.fn(), + readActiveClaudeKeychainCredentialsStrict: vi.fn(), + readManagedClaudeKeychainCredentials: vi.fn(), + writeActiveClaudeKeychainCredentials: vi.fn(), + writeManagedClaudeKeychainCredentials: vi.fn() +})) + +const configDir = '/Users/test/.claude' + +function systemAuth(): ClaudeRuntimeAuthPreparation { + return { configDir, envPatch: {}, stripAuthEnv: false, provenance: 'system' } +} + +function managedAuth( + overrides: Partial = {} +): ClaudeRuntimeAuthPreparation { + return { + configDir, + envPatch: {}, + stripAuthEnv: true, + provenance: 'managed:account-1', + ...overrides + } +} + +function storedLogin(accessToken: string | undefined, expiresInMs = -60_000): string { + return JSON.stringify({ + claudeAiOauth: { + ...(accessToken ? { accessToken } : {}), + refreshToken: 'refresh-token', + expiresAt: Date.now() + expiresInMs + } + }) +} + +function usageError(status: number, type: string): Response { + return new Response(JSON.stringify({ error: { type, message: 'nope' } }), { status }) +} + +function usage(session: number, weekly: number): Response { + return new Response( + JSON.stringify({ five_hour: { utilization: session }, seven_day: { utilization: weekly } }), + { status: 200 } + ) +} + +describe('fetchClaudeRateLimits without a hidden interactive Claude', () => { + beforeEach(() => { + primeClaudeFetcherMocks({ + netFetchMock, + readFileMock, + resolveProxyMock, + setProxyMock, + appGetPathMock + }) + }) + + afterEach(() => { + restorePlatform() + }) + + it('does not mask OAuth usage rate limits with a CLI refresh', async () => { + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + storedLogin('expired-oauth-token') + ) + netFetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ error: { type: 'rate_limit_error' } }), { + status: 429, + headers: { 'retry-after': '3000' } + }) + ) + + const before = Date.now() + const result = await fetchClaudeRateLimits({ + authPreparation: managedAuth(), + allowCliLoginRefresh: true + }) + expect(result).toMatchObject({ + status: 'error', + error: 'Claude usage is rate limited right now.', + usageMetadata: expect.objectContaining({ failureKind: 'rate-limited' }) + }) + expect(result.usageMetadata?.retryAtMs).toBeGreaterThanOrEqual(before + 3000 * 1000) + expect(refreshClaudeLoginViaCli).not.toHaveBeenCalled() + }) + + it('omits retryAtMs when a 429 has no Retry-After header', async () => { + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + storedLogin('expired-oauth-token') + ) + netFetchMock.mockResolvedValueOnce(usageError(429, 'rate_limit_error')) + + const result = await fetchClaudeRateLimits({ authPreparation: systemAuth() }) + expect(result.status).toBe('error') + expect(result.usageMetadata?.retryAtMs).toBeUndefined() + }) + + it('lets a managed account’s own Claude refresh an expired login, then reads usage with it', async () => { + const authPreparation = managedAuth() + vi.mocked(readActiveClaudeKeychainCredentialsStrict) + .mockResolvedValueOnce(storedLogin('stale-oauth-token')) + .mockResolvedValueOnce(storedLogin('refreshed-oauth-token', 60_000)) + netFetchMock.mockResolvedValueOnce(usageError(401, 'authentication_error')) + netFetchMock.mockResolvedValueOnce(usage(14, 27)) + + await expect( + fetchClaudeRateLimits({ authPreparation, allowCliLoginRefresh: true }) + ).resolves.toMatchObject({ + status: 'ok', + session: { usedPercent: 14 }, + weekly: { usedPercent: 27 }, + usageMetadata: { source: 'oauth', attemptedSources: ['oauth', 'cli'] } + }) + expect(refreshClaudeLoginViaCli).toHaveBeenCalledWith( + expect.objectContaining({ authPreparation }) + ) + expect(netFetchMock).toHaveBeenNthCalledWith( + 2, + 'https://api.anthropic.com/api/oauth/usage', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer refreshed-oauth-token' }) + }) + ) + }) + + it('reports the expired login, and backs off, when the CLI saved nothing new', async () => { + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue( + storedLogin('stale-oauth-token') + ) + netFetchMock.mockResolvedValue(usageError(401, 'authentication_error')) + const options = { authPreparation: managedAuth(), allowCliLoginRefresh: true } + + await expect(fetchClaudeRateLimits(options)).resolves.toMatchObject({ + status: 'error', + usageMetadata: { failureKind: 'stale-token' } + }) + await fetchClaudeRateLimits(options) + + // The usage answer is never trusted: only the stored login decides, so one probe per backoff. + expect(refreshClaudeLoginViaCli).toHaveBeenCalledTimes(1) + }) + + it('does not start a CLI that lacks get_usage again', async () => { + vi.mocked(refreshClaudeLoginViaCli).mockResolvedValue({ + kind: 'unsupported', + message: 'Unsupported control request subtype: get_usage' + }) + vi.mocked(readActiveClaudeKeychainCredentialsStrict) + .mockResolvedValueOnce(storedLogin('stale-oauth-token')) + .mockResolvedValueOnce(storedLogin('stale-oauth-token')) + .mockResolvedValueOnce(storedLogin('other-stale-token')) + netFetchMock.mockResolvedValue(usageError(401, 'authentication_error')) + const options = { authPreparation: managedAuth(), allowCliLoginRefresh: true } + + await fetchClaudeRateLimits(options) + await fetchClaudeRateLimits(options) + + expect(refreshClaudeLoginViaCli).toHaveBeenCalledTimes(1) + }) + + it('never starts Claude for a system-default login', async () => { + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + storedLogin('stale-oauth-token') + ) + netFetchMock.mockResolvedValueOnce(usageError(401, 'authentication_error')) + + await expect(fetchClaudeRateLimits({ authPreparation: systemAuth() })).resolves.toMatchObject({ + status: 'error', + usageMetadata: { failureKind: 'stale-token' } + }) + expect(refreshClaudeLoginViaCli).not.toHaveBeenCalled() + }) + + it('reports a server error instead of asking the CLI, which calls the same endpoint', async () => { + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + storedLogin('oauth-token', 60_000) + ) + netFetchMock.mockResolvedValueOnce(new Response('temporary failure', { status: 500 })) + + await expect( + fetchClaudeRateLimits({ authPreparation: managedAuth(), allowCliLoginRefresh: true }) + ).resolves.toMatchObject({ status: 'error', error: 'OAuth API returned 500' }) + expect(refreshClaudeLoginViaCli).not.toHaveBeenCalled() + }) + + it('explains auth failures when a live Claude terminal owns managed refresh', async () => { + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + storedLogin('stale-oauth-token') + ) + netFetchMock.mockResolvedValueOnce(usageError(401, 'authentication_error')) + + await expect( + fetchClaudeRateLimits({ + authPreparation: managedAuth({ managedRefreshDeferredByLivePty: true }), + allowCliLoginRefresh: true + }) + ).resolves.toMatchObject({ + status: 'error', + error: + 'Claude usage refresh is waiting for the live Claude terminal to rotate its credentials.' + }) + expect(refreshClaudeLoginViaCli).not.toHaveBeenCalled() + }) + + it('waits for a live Claude when no token is readable', async () => { + await expect( + fetchClaudeRateLimits({ + authPreparation: managedAuth({ managedRefreshDeferredByLivePty: true }), + allowCliLoginRefresh: true + }) + ).resolves.toMatchObject({ + status: 'error', + usageMetadata: { + failureKind: 'deferred-by-live-session', + deferredByLiveClaudeSession: true, + attemptedSources: [] + } + }) + expect(refreshClaudeLoginViaCli).not.toHaveBeenCalled() + }) + + it('refreshes refresh-only credentials through the CLI when allowed', async () => { + vi.mocked(readActiveClaudeKeychainCredentialsStrict) + .mockResolvedValueOnce(storedLogin(undefined)) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(storedLogin('refreshed-oauth-token', 60_000)) + + await expect( + fetchClaudeRateLimits({ authPreparation: managedAuth(), allowCliLoginRefresh: true }) + ).resolves.toMatchObject({ status: 'ok', session: { usedPercent: 12 } }) + expect(refreshClaudeLoginViaCli).toHaveBeenCalledTimes(1) + }) + + it('reports refresh-only credentials when the CLI refresh is not allowed', async () => { + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + storedLogin(undefined) + ) + + await expect(fetchClaudeRateLimits({ authPreparation: systemAuth() })).resolves.toMatchObject({ + status: 'error', + error: 'Claude OAuth access token unavailable' + }) + expect(refreshClaudeLoginViaCli).not.toHaveBeenCalled() + }) + + it('shows a signed-out managed account as an error, never by starting Claude', async () => { + await expect( + fetchClaudeRateLimits({ authPreparation: managedAuth(), allowCliLoginRefresh: true }) + ).resolves.toMatchObject({ + status: 'error', + error: 'Claude account is signed out', + usageMetadata: { failureKind: 'missing-credentials', attemptedSources: [] } + }) + expect(refreshClaudeLoginViaCli).not.toHaveBeenCalled() + }) + + it('keeps API-key billing wording for a system login with no subscription credentials', async () => { + await expect(fetchClaudeRateLimits({ authPreparation: systemAuth() })).resolves.toMatchObject({ + status: 'unavailable', + error: 'No subscription plan — API key billing', + usageMetadata: { failureKind: 'missing-credentials' } + }) + }) + + it('surfaces an unreadable Keychain without starting Claude, which reads it the same way', async () => { + vi.mocked(readActiveClaudeKeychainCredentials).mockRejectedValueOnce( + new Error('security timed out after 3000ms') + ) + + await expect(fetchClaudeRateLimits({ allowCliLoginRefresh: true })).resolves.toMatchObject({ + status: 'error', + error: 'Claude Keychain credentials unavailable', + usageMetadata: { failureKind: 'keychain-unavailable', attemptedSources: [] } + }) + expect(refreshClaudeLoginViaCli).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/rate-limits/claude-fetcher-fable-usage.test.ts b/src/main/rate-limits/claude-fetcher-fable-usage.test.ts index 2a84cc4f2af6..075d60e8d0de 100644 --- a/src/main/rate-limits/claude-fetcher-fable-usage.test.ts +++ b/src/main/rate-limits/claude-fetcher-fable-usage.test.ts @@ -1,7 +1,7 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' import { fetchClaudeRateLimits } from './claude-fetcher' import { primeClaudeFetcherMocks, restorePlatform } from './claude-fetcher-test-harness' -import { fetchViaPty } from './claude-pty' +import { refreshClaudeLoginViaCli } from './claude-cli-login-refresh' import { readActiveClaudeKeychainCredentialsStrict } from '../claude-accounts/keychain' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' @@ -34,8 +34,8 @@ vi.mock('electron', () => ({ } })) -vi.mock('./claude-pty', () => ({ - fetchViaPty: vi.fn() +vi.mock('./claude-cli-login-refresh', () => ({ + refreshClaudeLoginViaCli: vi.fn() })) vi.mock('../claude-accounts/keychain', () => ({ @@ -98,7 +98,7 @@ describe('fetchClaudeRateLimits', () => { }) }) - it('maps active Fable usage from the scoped OAuth limits array without a PTY read', async () => { + it('maps active Fable usage from the scoped OAuth limits array without starting Claude', async () => { const configDir = '/Users/test/.claude' const authPreparation: ClaudeRuntimeAuthPreparation = { configDir, @@ -131,7 +131,7 @@ describe('fetchClaudeRateLimits', () => { ) await expect( - fetchClaudeRateLimits({ authPreparation, allowUsagePanelSupplement: true }) + fetchClaudeRateLimits({ authPreparation, allowCliLoginRefresh: true }) ).resolves.toMatchObject({ provider: 'claude', status: 'ok', @@ -141,7 +141,25 @@ describe('fetchClaudeRateLimits', () => { }, usageMetadata: { attemptedSources: ['oauth'] } }) - expect(fetchViaPty).not.toHaveBeenCalled() + expect(refreshClaudeLoginViaCli).not.toHaveBeenCalled() + }) + + it('reports OAuth usage without a Fable window as-is instead of starting Claude to look', async () => { + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir: '/Users/test/.claude', + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ claudeAiOauth: { accessToken: 'oauth-token' } }) + ) + + const result = await fetchClaudeRateLimits({ authPreparation, allowCliLoginRefresh: true }) + + expect(result).toMatchObject({ status: 'ok', session: { usedPercent: 12 } }) + expect(result.fableWeekly ?? null).toBeNull() + expect(refreshClaudeLoginViaCli).not.toHaveBeenCalled() }) it('surfaces inactive scoped Fable usage over the legacy OAuth fallback', async () => { @@ -219,111 +237,6 @@ describe('fetchClaudeRateLimits', () => { }) }) - it('supplements managed-account OAuth usage with Fable from the CLI usage panel', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'managed:account-1' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - five_hour: { used_percentage: 23.5, resets_at: 1770000000 }, - seven_day: { used_percentage: 41.2, resets_at: 1770604800 } - }), - { status: 200 } - ) - ) - vi.mocked(fetchViaPty).mockResolvedValueOnce({ - provider: 'claude', - session: { usedPercent: 91, windowMinutes: 300, resetsAt: null, resetDescription: null }, - weekly: null, - fableWeekly: { - usedPercent: 12.3, - windowMinutes: 10080, - resetsAt: null, - resetDescription: '3d 2h' - }, - updatedAt: 1, - error: null, - status: 'ok' - }) - - await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 23.5, resetsAt: 1770000000000 }, - weekly: { usedPercent: 41.2, resetsAt: 1770604800000 }, - fableWeekly: { usedPercent: 12.3, resetDescription: '3d 2h' }, - usageMetadata: { - source: 'oauth', - attemptedSources: ['oauth', 'cli'] - } - }) - expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) - }) - - it('supplements system OAuth usage when the service explicitly allows usage-panel reads', async () => { - const configDir = '/Users/test/.claude' - const authPreparation: ClaudeRuntimeAuthPreparation = { - configDir, - envPatch: { CLAUDE_CONFIG_DIR: configDir }, - stripAuthEnv: false, - provenance: 'system' - } - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - vi.mocked(fetchViaPty).mockResolvedValueOnce({ - provider: 'claude', - session: null, - weekly: null, - fableWeekly: { - usedPercent: 58, - windowMinutes: 10080, - resetsAt: null, - resetDescription: '4d' - }, - updatedAt: 1, - error: null, - status: 'ok' - }) - - await expect( - fetchClaudeRateLimits({ - authPreparation, - allowPtyFallback: false, - allowUsagePanelSupplement: true - }) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 12 }, - weekly: { usedPercent: 34 }, - fableWeekly: { usedPercent: 58, resetDescription: '4d' }, - usageMetadata: { - source: 'oauth', - attemptedSources: ['oauth', 'cli'] - } - }) - expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) - }) - it('ignores bare Fable OAuth usage because the window length is ambiguous', async () => { const configDir = '/Users/test/.claude' const authPreparation: ClaudeRuntimeAuthPreparation = { diff --git a/src/main/rate-limits/claude-fetcher-keychain-credentials.test.ts b/src/main/rate-limits/claude-fetcher-keychain-credentials.test.ts index 9a75d3826e5c..e2dc59f981f9 100644 --- a/src/main/rate-limits/claude-fetcher-keychain-credentials.test.ts +++ b/src/main/rate-limits/claude-fetcher-keychain-credentials.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { join } from 'node:path' import { fetchClaudeRateLimits } from './claude-fetcher' import { primeClaudeFetcherMocks, restorePlatform } from './claude-fetcher-test-harness' -import { fetchViaPty } from './claude-pty' +import { refreshClaudeLoginViaCli } from './claude-cli-login-refresh' import { readActiveClaudeKeychainCredentials, readActiveClaudeKeychainCredentialsStrict @@ -38,8 +38,8 @@ vi.mock('electron', () => ({ } })) -vi.mock('./claude-pty', () => ({ - fetchViaPty: vi.fn() +vi.mock('./claude-cli-login-refresh', () => ({ + refreshClaudeLoginViaCli: vi.fn() })) vi.mock('../claude-accounts/keychain', () => ({ @@ -87,7 +87,7 @@ describe('fetchClaudeRateLimits', () => { expect(readFileMock).not.toHaveBeenCalled() expect(readActiveClaudeKeychainCredentialsStrict).not.toHaveBeenCalled() - expect(fetchViaPty).not.toHaveBeenCalled() + expect(refreshClaudeLoginViaCli).not.toHaveBeenCalled() }) it('reads scoped Keychain credentials when the Claude config dir is explicit', async () => { @@ -162,9 +162,7 @@ describe('fetchClaudeRateLimits', () => { ) }) - await expect( - fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) - ).resolves.toMatchObject({ + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ provider: 'claude', status: 'ok', session: { usedPercent: 12 }, @@ -172,7 +170,7 @@ describe('fetchClaudeRateLimits', () => { }) expect(netFetchMock).toHaveBeenCalledTimes(2) - expect(fetchViaPty).not.toHaveBeenCalled() + expect(refreshClaudeLoginViaCli).not.toHaveBeenCalled() }) it('prefers a legacy access token over scoped refresh-only credentials', async () => { @@ -221,9 +219,7 @@ describe('fetchClaudeRateLimits', () => { }) ) - await expect( - fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) - ).resolves.toMatchObject({ + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ provider: 'claude', status: 'error' }) @@ -256,9 +252,7 @@ describe('fetchClaudeRateLimits', () => { }) ) - await expect( - fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) - ).resolves.toMatchObject({ + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ provider: 'claude', status: 'error' }) @@ -286,9 +280,7 @@ describe('fetchClaudeRateLimits', () => { }) ) - await expect( - fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) - ).resolves.toMatchObject({ + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ provider: 'claude', status: 'error' }) @@ -355,9 +347,7 @@ describe('fetchClaudeRateLimits', () => { }) ) - await expect( - fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) - ).resolves.toMatchObject({ + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ provider: 'claude', status: 'ok', session: { usedPercent: 12 }, @@ -366,7 +356,7 @@ describe('fetchClaudeRateLimits', () => { expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(configDir) expect(readActiveClaudeKeychainCredentials).not.toHaveBeenCalled() - expect(fetchViaPty).not.toHaveBeenCalled() + expect(refreshClaudeLoginViaCli).not.toHaveBeenCalled() expect(netFetchMock).toHaveBeenCalledWith( 'https://api.anthropic.com/api/oauth/usage', expect.objectContaining({ @@ -486,6 +476,6 @@ describe('fetchClaudeRateLimits', () => { }) ) expect(readFileMock).not.toHaveBeenCalled() - expect(fetchViaPty).not.toHaveBeenCalled() + expect(refreshClaudeLoginViaCli).not.toHaveBeenCalled() }) }) diff --git a/src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts b/src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts index c01bcb00f6eb..27a1ac0b6338 100644 --- a/src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts +++ b/src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fetchManagedAccountUsage } from './claude-fetcher' @@ -8,13 +8,6 @@ import { restorePlatform, setPlatform } from './claude-fetcher-test-harness' -import { fetchViaPty } from './claude-pty' -import { - deleteActiveClaudeKeychainCredentialsStrict, - readManagedClaudeKeychainCredentials, - writeActiveClaudeKeychainCredentials, - writeManagedClaudeKeychainCredentials -} from '../claude-accounts/keychain' const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( () => ({ @@ -45,8 +38,8 @@ vi.mock('electron', () => ({ } })) -vi.mock('./claude-pty', () => ({ - fetchViaPty: vi.fn() +vi.mock('./claude-cli-login-refresh', () => ({ + refreshClaudeLoginViaCli: vi.fn() })) vi.mock('../claude-accounts/keychain', () => ({ @@ -109,268 +102,6 @@ describe('fetchClaudeRateLimits', () => { expect(readFileMock).not.toHaveBeenCalled() }) - it('supplements inactive managed account OAuth usage with Fable from its usage panel', async () => { - setPlatform('linux') - tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) - appGetPathMock.mockReturnValue(tempDir) - const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(ownedAuthPath, { recursive: true }) - writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - const canonicalAuthPath = realpathSync(ownedAuthPath) - writeFileSync( - join(ownedAuthPath, '.credentials.json'), - JSON.stringify({ - claudeAiOauth: { - accessToken: 'inactive-token', - expiresAt: Date.now() + 60_000 - } - }), - 'utf-8' - ) - vi.mocked(fetchViaPty).mockResolvedValueOnce({ - provider: 'claude', - session: null, - weekly: null, - fableWeekly: { - usedPercent: 42, - windowMinutes: 10080, - resetsAt: null, - resetDescription: '2d' - }, - updatedAt: 1, - error: null, - status: 'ok' - }) - - await expect( - fetchManagedAccountUsage( - { id: 'account-1', managedAuthPath: ownedAuthPath }, - { allowUsagePanelSupplement: true } - ) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 12 }, - weekly: { usedPercent: 34 }, - fableWeekly: { usedPercent: 42, resetDescription: '2d' } - }) - expect(fetchViaPty).toHaveBeenCalledWith({ - authPreparation: expect.objectContaining({ - configDir: canonicalAuthPath, - envPatch: { CLAUDE_CONFIG_DIR: canonicalAuthPath }, - provenance: 'managed:account-1:inactive-preview', - stripAuthEnv: true - }) - }) - }) - - it('stages macOS inactive account credentials in a scoped Keychain for Fable preview', async () => { - setPlatform('darwin') - tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) - appGetPathMock.mockReturnValue(tempDir) - const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - const credentialsJson = JSON.stringify({ - claudeAiOauth: { - accessToken: 'managed-keychain-token', - expiresAt: Date.now() + 60_000 - } - }) - mkdirSync(ownedAuthPath, { recursive: true }) - writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - const canonicalAuthPath = realpathSync(ownedAuthPath) - vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce(credentialsJson) - vi.mocked(fetchViaPty).mockResolvedValueOnce({ - provider: 'claude', - session: { - usedPercent: 12, - windowMinutes: 300, - resetsAt: null, - resetDescription: null - }, - weekly: { - usedPercent: 34, - windowMinutes: 10080, - resetsAt: null, - resetDescription: null - }, - fableWeekly: { - usedPercent: 58, - windowMinutes: 10080, - resetsAt: null, - resetDescription: '3d' - }, - updatedAt: 1, - error: null, - status: 'ok' - }) - - const result = await fetchManagedAccountUsage( - { id: 'account-1', managedAuthPath: ownedAuthPath }, - { allowUsagePanelSupplement: true } - ) - - expect(result.fableWeekly).toMatchObject({ usedPercent: 58, resetDescription: '3d' }) - expect(writeActiveClaudeKeychainCredentials).toHaveBeenCalledWith( - credentialsJson, - canonicalAuthPath - ) - expect(deleteActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(canonicalAuthPath) - }) - - it('cleans up scoped Keychain credentials when the inactive preview fails', async () => { - setPlatform('darwin') - tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) - appGetPathMock.mockReturnValue(tempDir) - const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(ownedAuthPath, { recursive: true }) - writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - const canonicalAuthPath = realpathSync(ownedAuthPath) - vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce( - JSON.stringify({ claudeAiOauth: { accessToken: 'managed-keychain-token' } }) - ) - vi.mocked(fetchViaPty).mockRejectedValueOnce(new Error('preview failed')) - - await expect( - fetchManagedAccountUsage( - { id: 'account-1', managedAuthPath: ownedAuthPath }, - { allowUsagePanelSupplement: true } - ) - ).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { usedPercent: 12 }, - weekly: { usedPercent: 34 }, - fableWeekly: null - }) - - expect(deleteActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(canonicalAuthPath) - }) - - it('stages refreshed macOS inactive account credentials before Fable preview', async () => { - setPlatform('darwin') - tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) - appGetPathMock.mockReturnValue(tempDir) - const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - const staleCredentialsJson = JSON.stringify({ - claudeAiOauth: { - accessToken: 'stale-access', - refreshToken: 'stale-refresh', - expiresAt: Date.now() - 60_000 - } - }) - mkdirSync(ownedAuthPath, { recursive: true }) - writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce(staleCredentialsJson) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - access_token: 'fresh-access', - expires_in: 3600, - refresh_token: 'fresh-refresh' - }), - { status: 200 } - ) - ) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ five_hour: { utilization: 12 }, seven_day: { utilization: 34 } }), - { - status: 200 - } - ) - ) - vi.mocked(fetchViaPty).mockResolvedValueOnce({ - provider: 'claude', - session: { - usedPercent: 12, - windowMinutes: 300, - resetsAt: null, - resetDescription: null - }, - weekly: { - usedPercent: 34, - windowMinutes: 10080, - resetsAt: null, - resetDescription: null - }, - fableWeekly: { - usedPercent: 58, - windowMinutes: 10080, - resetsAt: null, - resetDescription: '3d' - }, - updatedAt: 1, - error: null, - status: 'ok' - }) - - const result = await fetchManagedAccountUsage( - { id: 'account-1', managedAuthPath: ownedAuthPath }, - { allowUsagePanelSupplement: true } - ) - - const stagedCredentialsJson = vi.mocked(writeActiveClaudeKeychainCredentials).mock.calls[0]?.[0] - expect(result.fableWeekly).toMatchObject({ usedPercent: 58, resetDescription: '3d' }) - expect(JSON.parse(stagedCredentialsJson ?? '{}')).toMatchObject({ - claudeAiOauth: { - accessToken: 'fresh-access', - refreshToken: 'fresh-refresh' - } - }) - expect(writeManagedClaudeKeychainCredentials).toHaveBeenCalledWith( - 'account-1', - stagedCredentialsJson - ) - }) - - it('does not merge macOS inactive Fable preview when usage windows belong to another account', async () => { - setPlatform('darwin') - tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) - appGetPathMock.mockReturnValue(tempDir) - const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') - mkdirSync(ownedAuthPath, { recursive: true }) - writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') - vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'managed-keychain-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - vi.mocked(fetchViaPty).mockResolvedValueOnce({ - provider: 'claude', - session: { - usedPercent: 91, - windowMinutes: 300, - resetsAt: null, - resetDescription: null - }, - weekly: { - usedPercent: 3, - windowMinutes: 10080, - resetsAt: null, - resetDescription: null - }, - fableWeekly: { - usedPercent: 58, - windowMinutes: 10080, - resetsAt: null, - resetDescription: '3d' - }, - updatedAt: 1, - error: null, - status: 'ok' - }) - - const result = await fetchManagedAccountUsage( - { id: 'account-1', managedAuthPath: ownedAuthPath }, - { allowUsagePanelSupplement: true } - ) - - expect(result.fableWeekly).toBeNull() - }) - it('refreshes and persists an expiring inactive account before fetching usage', async () => { setPlatform('linux') tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) diff --git a/src/main/rate-limits/claude-fetcher-test-harness.ts b/src/main/rate-limits/claude-fetcher-test-harness.ts index f679d8388337..12e9c83ccd65 100644 --- a/src/main/rate-limits/claude-fetcher-test-harness.ts +++ b/src/main/rate-limits/claude-fetcher-test-harness.ts @@ -1,6 +1,7 @@ import { vi } from 'vitest' import type { Mock } from 'vitest' -import { fetchViaPty } from './claude-pty' +import { refreshClaudeLoginViaCli } from './claude-cli-login-refresh' +import { resetClaudeLoginRefreshLatchesForTests } from './claude-oauth-recovery' import { deleteActiveClaudeKeychainCredentialsStrict, readActiveClaudeKeychainCredentials, @@ -34,10 +35,11 @@ export function restorePlatform(): void { } } -/** Default happy-path state: darwin, no keychain credentials, OAuth usage 12/34, PTY session 56. */ +/** Default happy-path state: darwin, no keychain credentials, OAuth usage 12/34, CLI refresh answers. */ export function primeClaudeFetcherMocks(mocks: ClaudeFetcherHoistedMocks): void { setPlatform('darwin') vi.clearAllMocks() + resetClaudeLoginRefreshLatchesForTests() mocks.readFileMock.mockRejectedValue(new Error('missing file')) vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue(null) vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue(null) @@ -56,12 +58,5 @@ export function primeClaudeFetcherMocks(mocks: ClaudeFetcherHoistedMocks): void { status: 200 } ) ) - vi.mocked(fetchViaPty).mockResolvedValue({ - provider: 'claude', - session: { usedPercent: 56, windowMinutes: 300, resetsAt: null, resetDescription: null }, - weekly: null, - updatedAt: 1, - error: null, - status: 'ok' - }) + vi.mocked(refreshClaudeLoginViaCli).mockResolvedValue({ kind: 'answered' }) } diff --git a/src/main/rate-limits/claude-managed-account-credentials.ts b/src/main/rate-limits/claude-managed-account-credentials.ts index 71d505510cf1..ffc51179afd9 100644 --- a/src/main/rate-limits/claude-managed-account-credentials.ts +++ b/src/main/rate-limits/claude-managed-account-credentials.ts @@ -2,10 +2,7 @@ import { existsSync, lstatSync, readFileSync } from 'node:fs' import path from 'node:path' import { parseWslUncPath } from '../../shared/wsl-paths' import { - deleteActiveClaudeKeychainCredentialsStrict, - readActiveClaudeKeychainCredentialsStrict, readManagedClaudeKeychainCredentials, - writeActiveClaudeKeychainCredentials, writeManagedClaudeKeychainCredentials } from '../claude-accounts/keychain' import { @@ -96,32 +93,3 @@ function resolveOwnedWslClaudeManagedAuthPath(account: InactiveClaudeAccount): s return null } } - -export async function withClaudeManagedPreviewKeychainCredentials( - location: ClaudeManagedCredentialsLocation, - credentialsJson: string, - operation: () => Promise -): Promise { - if (location.kind !== 'keychain') { - return operation() - } - await writeActiveClaudeKeychainCredentials(credentialsJson, location.managedAuthPath) - try { - return await operation() - } finally { - await deleteActiveClaudeKeychainCredentialsStrict(location.managedAuthPath).catch(() => {}) - } -} - -export async function readStagedClaudeManagedPreviewCredentials( - location: ClaudeManagedCredentialsLocation -): Promise { - if (location.kind !== 'keychain') { - return null - } - try { - return await readActiveClaudeKeychainCredentialsStrict(location.managedAuthPath) - } catch { - return null - } -} diff --git a/src/main/rate-limits/claude-managed-account-usage.ts b/src/main/rate-limits/claude-managed-account-usage.ts index ddf7b3df7508..32344928b54e 100644 --- a/src/main/rate-limits/claude-managed-account-usage.ts +++ b/src/main/rate-limits/claude-managed-account-usage.ts @@ -9,16 +9,10 @@ import { writeClaudeManagedCredentialsJson, type InactiveClaudeAccount } from './claude-managed-account-credentials' -import { fetchClaudeManagedUsagePanelSupplement } from './claude-managed-usage-panel' import { parseClaudeOAuthCredentialsJson } from './claude-oauth-credentials' import { fetchClaudeOAuthUsage } from './claude-oauth-usage-request' import type { ClaudeManagedAccountUsageOptions } from './claude-usage-fetch-options' -import { - abortedClaudeRateLimitResult, - canSupplementClaudeOAuthUsage, - mergeClaudeUsageWindows, - warnClaudeUsageFetchFailure -} from './claude-usage-result' +import { abortedClaudeRateLimitResult } from './claude-usage-result' function noClaudeManagedCredentialsResult(): ProviderRateLimits { return { @@ -39,7 +33,7 @@ export async function fetchInactiveClaudeAccountUsage( return abortedClaudeRateLimitResult() } const location = resolveClaudeManagedCredentialsLocation(account) - let credentialsJson = location ? await readClaudeManagedCredentialsJson(location) : null + const credentialsJson = location ? await readClaudeManagedCredentialsJson(location) : null if (options.signal?.aborted) { return abortedClaudeRateLimitResult() } @@ -59,7 +53,6 @@ export async function fetchInactiveClaudeAccountUsage( } catch { // Keep the refreshed token for this fetch; a later poll can persist it. } - credentialsJson = refreshed token = parseClaudeOAuthCredentialsJson(refreshed, 'credentials-file').token } } @@ -67,38 +60,6 @@ export async function fetchInactiveClaudeAccountUsage( if (!token) { return noClaudeManagedCredentialsResult() } - const oauthLimits = await fetchClaudeOAuthUsage(token, options.signal) - if (options.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - if ( - !canSupplementClaudeOAuthUsage({ - oauthLimits, - authPreparation: undefined, - allowUsagePanelSupplement: options.allowUsagePanelSupplement === true - }) - ) { - return oauthLimits - } - - try { - return mergeClaudeUsageWindows( - oauthLimits, - await fetchClaudeManagedUsagePanelSupplement({ - account, - location, - credentialsJson, - oauthLimits, - networkProxySettings: options.networkProxySettings, - signal: options.signal - }) - ) - } catch (error) { - warnClaudeUsageFetchFailure( - undefined, - parseClaudeOAuthCredentialsJson(credentialsJson, 'credentials-file'), - error - ) - return oauthLimits - } + const limits = await fetchClaudeOAuthUsage(token, options.signal) + return options.signal?.aborted ? abortedClaudeRateLimitResult() : limits } diff --git a/src/main/rate-limits/claude-managed-usage-panel.ts b/src/main/rate-limits/claude-managed-usage-panel.ts deleted file mode 100644 index 15c1e90169c7..000000000000 --- a/src/main/rate-limits/claude-managed-usage-panel.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' -import type { NetworkProxySettings } from '../../shared/network-proxy' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' -import { fetchViaPty } from './claude-pty' -import { - readStagedClaudeManagedPreviewCredentials, - withClaudeManagedPreviewKeychainCredentials, - writeClaudeManagedCredentialsJson, - type ClaudeManagedCredentialsLocation, - type InactiveClaudeAccount -} from './claude-managed-account-credentials' - -function getManagedUsagePanelAuthPreparation( - account: InactiveClaudeAccount, - location: ClaudeManagedCredentialsLocation -): ClaudeRuntimeAuthPreparation | null { - if (process.platform === 'win32') { - return null - } - if (account.managedAuthRuntime === 'wsl') { - if (!account.wslLinuxAuthPath || !account.wslDistro) { - return null - } - return { - configDir: location.managedAuthPath, - runtime: 'wsl', - wslDistro: account.wslDistro, - wslLinuxConfigDir: account.wslLinuxAuthPath, - envPatch: { CLAUDE_CONFIG_DIR: account.wslLinuxAuthPath }, - stripAuthEnv: true, - provenance: `managed:${account.id}:inactive-preview` - } - } - return { - configDir: location.managedAuthPath, - runtime: 'host', - wslDistro: null, - wslLinuxConfigDir: null, - envPatch: { CLAUDE_CONFIG_DIR: location.managedAuthPath }, - stripAuthEnv: true, - provenance: `managed:${account.id}:inactive-preview` - } -} - -function windowsAgree(left: RateLimitWindow | null, right: RateLimitWindow | null): boolean { - return Boolean(left && right && Math.abs(left.usedPercent - right.usedPercent) <= 1) -} - -function canTrustManagedUsagePanelSupplement( - oauthLimits: ProviderRateLimits, - cliLimits: ProviderRateLimits, - requireMatchingOAuthWindow: boolean -): boolean { - if (!requireMatchingOAuthWindow) { - return true - } - const sharedWindowMatches = [ - oauthLimits.session && cliLimits.session - ? windowsAgree(oauthLimits.session, cliLimits.session) - : null, - oauthLimits.weekly && cliLimits.weekly - ? windowsAgree(oauthLimits.weekly, cliLimits.weekly) - : null - ].filter((match): match is boolean => match !== null) - // Why: older Claude builds may ignore the scoped Keychain and expose the active account. - return sharedWindowMatches.length > 0 && sharedWindowMatches.every(Boolean) -} - -export async function fetchClaudeManagedUsagePanelSupplement(input: { - account: InactiveClaudeAccount - location: ClaudeManagedCredentialsLocation - credentialsJson: string - oauthLimits: ProviderRateLimits - networkProxySettings?: NetworkProxySettings - signal?: AbortSignal -}): Promise { - if (input.signal?.aborted) { - return null - } - const authPreparation = getManagedUsagePanelAuthPreparation(input.account, input.location) - if (!authPreparation) { - return null - } - - return withClaudeManagedPreviewKeychainCredentials( - input.location, - input.credentialsJson, - async () => { - const cliLimits = await fetchViaPty({ - authPreparation, - networkProxySettings: input.networkProxySettings, - signal: input.signal - }) - if (input.signal?.aborted) { - return null - } - if ( - !canTrustManagedUsagePanelSupplement( - input.oauthLimits, - cliLimits, - input.location.kind === 'keychain' - ) - ) { - return null - } - const refreshed = await readStagedClaudeManagedPreviewCredentials(input.location) - if (refreshed && refreshed !== input.credentialsJson) { - await writeClaudeManagedCredentialsJson(input.location, refreshed) - } - return cliLimits - } - ) -} diff --git a/src/main/rate-limits/claude-oauth-recovery.ts b/src/main/rate-limits/claude-oauth-recovery.ts index 58c6bf91cb37..d72176f0eeb1 100644 --- a/src/main/rate-limits/claude-oauth-recovery.ts +++ b/src/main/rate-limits/claude-oauth-recovery.ts @@ -1,7 +1,9 @@ +import { realpathSync, statSync } from 'node:fs' import type { ProviderRateLimits } from '../../shared/rate-limit-types' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' +import { resolveClaudeCommand } from '../codex-cli/command' import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' -import { completeClaudeOAuthUsageSuccess, fetchClaudeUsageViaCli } from './claude-cli-usage-fetch' +import { refreshClaudeLoginViaCli } from './claude-cli-login-refresh' import { readClaudeCredentialsFromStrictKeychain, readClaudeOAuthCredentials, @@ -14,14 +16,13 @@ import type { ClaudeUsageErrorClassification } from './claude-usage-error-classi import type { ClaudeRateLimitFetchOptions } from './claude-usage-fetch-options' import { abortedClaudeRateLimitResult, + claudeOAuthUsageSuccess, isManagedClaudeAuth, makeClaudeUsageResult, - mergeClaudeUsageWindows, metadataForClaudeUsageAttempt, recordClaudeUsageAttempt, type ClaudeUsageAttemptState, - warnClaudeUsageFetchFailure, - withClaudeUsageMetadata + warnClaudeUsageFetchFailure } from './claude-usage-result' const LIVE_REFRESH_DEFERRED_MESSAGE = @@ -57,11 +58,11 @@ export async function retryClaudeOAuthWithLegacyKeychain(input: { if (input.options?.signal?.aborted) { return abortedClaudeRateLimitResult() } - return await completeClaudeOAuthUsageSuccess({ - oauthLimits: limits, + return claudeOAuthUsageSuccess({ + limits, oauthCredentials: legacy, attempts: input.attempts, - options: input.options + authPreparation: input.options?.authPreparation }) } catch (error) { warnClaudeUsageFetchFailure(input.options?.authPreparation, legacy, error) @@ -118,55 +119,111 @@ export function makeClaudeUsageClassificationError(input: { }) } +// Both latches die on their own: a login that failed to refresh is skipped until its stored +// credentials change or the backoff ends (a dead login and a network blip look the same from +// here), and a CLI without get_usage until the installed binary changes. +const FAILED_LOGIN_REFRESH_BACKOFF_MS = 15 * 60_000 +const failedLoginRefreshByProvenance = new Map() +const unsupportedCliBinaries = new Set() + +function credentialStateKey(credentials: ClaudeOAuthCredentialReadResult): string { + return `${credentials.source}:${credentials.token ?? ''}:${credentials.hasRefreshableCredentials}` +} + +function claudeBinaryKey(command: string): string { + try { + const real = realpathSync(command) + return `${real}:${statSync(real).mtimeMs}` + } catch { + return command + } +} + +/** Test seam: the latches are process-wide. */ +export function resetClaudeLoginRefreshLatchesForTests(): void { + failedLoginRefreshByProvenance.clear() + unsupportedCliBinaries.clear() +} + +/** + * Lets the account's own Claude CLI refresh its expired login, then retries the usage endpoint + * with whatever it saved. Null means usage is still unknown and the caller reports why. + */ export async function repairClaudeCredentialsThenRetryOAuth(input: { options?: ClaudeRateLimitFetchOptions attempts: ClaudeUsageAttemptState oauthCredentials: ClaudeOAuthCredentialReadResult }): Promise { + const authPreparation = input.options?.authPreparation if (input.options?.signal?.aborted) { return abortedClaudeRateLimitResult() } - let cliResult: ProviderRateLimits | null = null - try { - cliResult = await fetchClaudeUsageViaCli({ - authPreparation: input.options?.authPreparation, - oauthCredentials: input.oauthCredentials, - attempts: input.attempts, - networkProxySettings: input.options?.networkProxySettings, - signal: input.options?.signal - }) - } catch (error) { - warnClaudeUsageFetchFailure(input.options?.authPreparation, input.oauthCredentials, error) + if (!authPreparation) { + return null + } + const before = credentialStateKey(input.oauthCredentials) + const failed = failedLoginRefreshByProvenance.get(authPreparation.provenance) + if (failed?.state === before && Date.now() < failed.retryAtMs) { + return null + } + const command = resolveClaudeCommand() + const binary = claudeBinaryKey(command) + if (unsupportedCliBinaries.has(binary)) { + return null + } + recordClaudeUsageAttempt(input.attempts, 'cli') + const outcome = await refreshClaudeLoginViaCli({ + authPreparation, + networkProxySettings: input.options?.networkProxySettings, + signal: input.options?.signal, + resolveCommand: () => command + }) + if (outcome.kind === 'unsupported') { + unsupportedCliBinaries.add(binary) + } + if (outcome.kind !== 'answered') { + warnClaudeUsageFetchFailure( + authPreparation, + input.oauthCredentials, + new Error(`Claude CLI login refresh ${outcome.kind}: ${outcome.message}`) + ) } if (input.options?.signal?.aborted) { return abortedClaudeRateLimitResult() } const refreshed = await readClaudeOAuthCredentials( - resolveClaudeOAuthCredentialReadOptions(input.options?.authPreparation) + resolveClaudeOAuthCredentialReadOptions(authPreparation) ) if (input.options?.signal?.aborted) { return abortedClaudeRateLimitResult() } - if (refreshed.token) { - recordClaudeUsageAttempt(input.attempts, 'oauth') - try { - const retry = await fetchClaudeOAuthUsage(refreshed.token, input.options?.signal) - if (input.options?.signal?.aborted) { - return abortedClaudeRateLimitResult() - } - return withClaudeUsageMetadata( - mergeClaudeUsageWindows(retry, cliResult), - metadataForClaudeUsageAttempt({ - attemptedSources: input.attempts.attemptedSources, - oauthCredentials: refreshed, - authPreparation: input.options?.authPreparation, - source: 'oauth' - }) - ) - } catch (error) { - warnClaudeUsageFetchFailure(input.options?.authPreparation, refreshed, error) + if (credentialStateKey(refreshed) === before) { + // The CLI saved nothing new; asking again before the login changes would fail the same way. + failedLoginRefreshByProvenance.set(authPreparation.provenance, { + state: before, + retryAtMs: Date.now() + FAILED_LOGIN_REFRESH_BACKOFF_MS + }) + return null + } + failedLoginRefreshByProvenance.delete(authPreparation.provenance) + if (!refreshed.token) { + return null + } + recordClaudeUsageAttempt(input.attempts, 'oauth') + try { + const limits = await fetchClaudeOAuthUsage(refreshed.token, input.options?.signal) + if (input.options?.signal?.aborted) { + return abortedClaudeRateLimitResult() } + return claudeOAuthUsageSuccess({ + limits, + oauthCredentials: refreshed, + attempts: input.attempts, + authPreparation + }) + } catch (error) { + warnClaudeUsageFetchFailure(authPreparation, refreshed, error) + return null } - return cliResult } diff --git a/src/main/rate-limits/claude-pty-reset-parser.ts b/src/main/rate-limits/claude-pty-reset-parser.ts deleted file mode 100644 index 43137a8cea54..000000000000 --- a/src/main/rate-limits/claude-pty-reset-parser.ts +++ /dev/null @@ -1,307 +0,0 @@ -import type { RateLimitWindow } from '../../shared/rate-limit-types' -import { buildWallClockTimestamp } from './time-zone-wall-clock' - -const RESET_LINE_RE = /resets?\s+(?:at\s+|in\s+)?(.+)/i -const MONTH_PATTERN = - 'jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?' -const MONTH_DAY_COMPACT_RE = new RegExp( - `\\b(${MONTH_PATTERN})(\\d{1,2})(?=\\s*at\\s*\\d|\\D|$)`, - 'i' -) -const MONTH_DAY_TIME_RE = new RegExp( - `\\b(${MONTH_PATTERN})\\.?\\s+(\\d{1,2})(?:,?\\s*(?:at\\s+)?)?(\\d{1,2})(?::(\\d{2}))?\\s*(am|pm)\\b`, - 'i' -) -const WEEKDAY_TIME_RE = - /\b(sun(?:day)?|mon(?:day)?|tue(?:sday)?|wed(?:nesday)?|thu(?:rsday)?|fri(?:day)?|sat(?:urday)?)\.?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i -const TIME_ONLY_RE = /\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i -// Why: newer Codex CLIs print 24-hour reset times ("10:21 on 28 Jul") with no am/pm. -const TIME_24H_RE = /\b(\d{1,2}):(\d{2})\b/ -const DAY_MONTH_RE = new RegExp(`\\b(?:on\\s+)?(\\d{1,2})\\s+(${MONTH_PATTERN})\\b`, 'i') -const RELATIVE_RESET_RE = /^(?:\s*\d+\s*(?:d(?:ays?)?|h(?:ours?|rs?)?|m(?:in(?:ute)?s?)?)\s*)+$/i -const RELATIVE_RESET_TOKEN_RE = /(\d+)\s*(d(?:ays?)?|h(?:ours?|rs?)?|m(?:in(?:ute)?s?)?)/gi -const IANA_TIME_ZONE_RE = /\(([^()]*)\)?\s*$/ - -export type ClaudePtyResetMetadata = Pick - -const MONTH_INDEX_BY_NAME: Record = { - jan: 0, - january: 0, - feb: 1, - february: 1, - mar: 2, - march: 2, - apr: 3, - april: 3, - may: 4, - jun: 5, - june: 5, - jul: 6, - july: 6, - aug: 7, - august: 7, - sep: 8, - sept: 8, - september: 8, - oct: 9, - october: 9, - nov: 10, - november: 10, - dec: 11, - december: 11 -} - -const WEEKDAY_INDEX_BY_NAME: Record = { - sun: 0, - sunday: 0, - mon: 1, - monday: 1, - tue: 2, - tuesday: 2, - wed: 3, - wednesday: 3, - thu: 4, - thursday: 4, - fri: 5, - friday: 5, - sat: 6, - saturday: 6 -} - -export function extractClaudePtyResetMetadata( - lines: string[], - matchesLabel: (line: string) => boolean, - isSectionLabel: (line: string) => boolean -): ClaudePtyResetMetadata { - for (let i = 0; i < lines.length; i++) { - if (!matchesLabel(lines[i])) { - continue - } - for (let j = i; j < Math.min(i + 14, lines.length); j++) { - if (j > i && isSectionLabel(lines[j])) { - break - } - const m = RESET_LINE_RE.exec(lines[j]) - if (m) { - const resetDescription = normalizeResetDescription(m[1]) - return { - resetsAt: parseResetTimestamp(resetDescription), - resetDescription - } - } - } - } - return { resetsAt: null, resetDescription: null } -} - -function normalizeResetDescription(raw: string): string { - // Why: Claude's TUI occasionally drops spaces around the Fable reset date - // when copied from the PTY buffer, but the value still encodes a real reset. - return ( - raw - .trim() - // Why: PTY captures keep trailing box-border glyphs from framed status panels. - .replace(/[)\s│]+$/, '') - .replace(/\s+/g, ' ') - .replace(MONTH_DAY_COMPACT_RE, '$1 $2') - .replace(/(\d{1,2})\s*at\s*(\d{1,2}(?::\d{2})?\s*(?:am|pm))/i, '$1 at $2') - .replace(/(\d)(am|pm)\(/gi, '$1$2 (') - ) -} - -function parseResetTimestamp(resetDescription: string | null): number | null { - if (!resetDescription) { - return null - } - - return ( - parseRelativeResetTimestamp(resetDescription) ?? - parseMonthDayResetTimestamp(resetDescription) ?? - parseWeekdayResetTimestamp(resetDescription) ?? - parseTimeOnlyResetTimestamp(resetDescription) ?? - parseTwentyFourHourResetTimestamp(resetDescription) - ) -} - -function parseTwentyFourHourResetTimestamp(resetDescription: string): number | null { - const resetText = stripResetTimeZone(resetDescription) - const timeMatch = TIME_24H_RE.exec(resetText) - if (!timeMatch) { - return null - } - const hour = Number(timeMatch[1]) - const minute = Number(timeMatch[2]) - if (!isValidClockTime(hour, minute)) { - return null - } - - const dayMonthMatch = DAY_MONTH_RE.exec(resetText) - if (dayMonthMatch) { - const day = Number(dayMonthMatch[1]) - const monthIndex = MONTH_INDEX_BY_NAME[dayMonthMatch[2].toLowerCase()] - if (monthIndex === undefined || day < 1 || day > 31) { - return null - } - const now = new Date() - const timeZone = extractResetTimeZone(resetDescription) - let timestamp = buildWallClockTimestamp( - { year: now.getFullYear(), monthIndex, day, hour, minute }, - timeZone - ) - if (timestamp !== null && timestamp <= Date.now()) { - timestamp = buildWallClockTimestamp( - { year: now.getFullYear() + 1, monthIndex, day, hour, minute }, - timeZone - ) - } - return timestamp - } - - const candidate = new Date() - candidate.setHours(hour, minute, 0, 0) - if (candidate.getTime() <= Date.now()) { - candidate.setDate(candidate.getDate() + 1) - } - return candidate.getTime() -} - -function parseRelativeResetTimestamp(resetDescription: string): number | null { - if (!RELATIVE_RESET_RE.test(resetDescription)) { - return null - } - - let durationMs = 0 - for (const match of resetDescription.matchAll(RELATIVE_RESET_TOKEN_RE)) { - const amount = Number(match[1]) - const unit = match[2].toLowerCase()[0] - if (!Number.isFinite(amount)) { - continue - } - if (unit === 'd') { - durationMs += amount * 24 * 60 * 60_000 - } else if (unit === 'h') { - durationMs += amount * 60 * 60_000 - } else if (unit === 'm') { - durationMs += amount * 60_000 - } - } - return durationMs > 0 ? Date.now() + durationMs : null -} - -function parseMonthDayResetTimestamp(resetDescription: string): number | null { - const resetText = stripResetTimeZone(resetDescription) - const match = MONTH_DAY_TIME_RE.exec(resetText) - if (!match) { - return null - } - - const monthIndex = MONTH_INDEX_BY_NAME[match[1].toLowerCase()] - if (monthIndex === undefined) { - return null - } - - const day = Number(match[2]) - const hour = parseHour(match[3], match[5]) - const minute = Number(match[4] ?? 0) - if (!isValidClockTime(hour, minute)) { - return null - } - - const now = new Date() - const timeZone = extractResetTimeZone(resetDescription) - let timestamp = buildWallClockTimestamp( - { year: now.getFullYear(), monthIndex, day, hour, minute }, - timeZone - ) - if (timestamp !== null && timestamp <= Date.now()) { - timestamp = buildWallClockTimestamp( - { year: now.getFullYear() + 1, monthIndex, day, hour, minute }, - timeZone - ) - } - return timestamp -} - -function parseWeekdayResetTimestamp(resetDescription: string): number | null { - const resetText = stripResetTimeZone(resetDescription) - const match = WEEKDAY_TIME_RE.exec(resetText) - if (!match) { - return null - } - - const weekdayIndex = WEEKDAY_INDEX_BY_NAME[match[1].toLowerCase()] - if (weekdayIndex === undefined) { - return null - } - - const now = new Date() - const hour = parseHour(match[2], match[4]) - const minute = Number(match[3] ?? 0) - if (!isValidClockTime(hour, minute)) { - return null - } - - const candidate = new Date(now) - const daysUntil = (weekdayIndex - now.getDay() + 7) % 7 - candidate.setDate(now.getDate() + daysUntil) - candidate.setHours(hour, minute, 0, 0) - if (candidate.getTime() <= Date.now()) { - candidate.setDate(candidate.getDate() + 7) - } - return candidate.getTime() -} - -function parseTimeOnlyResetTimestamp(resetDescription: string): number | null { - const resetText = stripResetTimeZone(resetDescription) - const match = TIME_ONLY_RE.exec(resetText) - if (!match) { - return null - } - - const hour = parseHour(match[1], match[3]) - const minute = Number(match[2] ?? 0) - if (!isValidClockTime(hour, minute)) { - return null - } - - const candidate = new Date() - candidate.setHours(hour, minute, 0, 0) - if (candidate.getTime() <= Date.now()) { - candidate.setDate(candidate.getDate() + 1) - } - return candidate.getTime() -} - -function parseHour(hourText: string, periodText: string): number { - const hour = Number(hourText) - const period = periodText.toLowerCase() - if (hour < 1 || hour > 12) { - return Number.NaN - } - if (period === 'am') { - return hour === 12 ? 0 : hour - } - return hour === 12 ? 12 : hour + 12 -} - -function isValidClockTime(hour: number, minute: number): boolean { - return Number.isInteger(hour) && hour >= 0 && hour < 24 && minute >= 0 && minute < 60 -} - -function stripResetTimeZone(resetDescription: string): string { - return resetDescription.replace(/\s*\([^)]*\)?\s*$/, '').trim() -} - -function extractResetTimeZone(resetDescription: string): string | null { - const match = IANA_TIME_ZONE_RE.exec(resetDescription) - if (!match?.[1]) { - return null - } - const timeZone = match[1].trim() - try { - new Intl.DateTimeFormat('en-US', { timeZone }) - return timeZone - } catch { - return null - } -} diff --git a/src/main/rate-limits/claude-pty-stop-markers.ts b/src/main/rate-limits/claude-pty-stop-markers.ts deleted file mode 100644 index 2bba56c69112..000000000000 --- a/src/main/rate-limits/claude-pty-stop-markers.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Why: these substrings indicate the /usage TUI panel has finished rendering. -export const CLAUDE_USAGE_STOP_SUBSTRINGS = [ - 'Current week (all models)', - 'Current week (Opus)', - 'Current week (Sonnet only)', - 'Current week (Sonnet)', - 'Weekly limits', - 'Weekly limit', - 'Weekly usage', - '7-day', - 'Current session', - 'Failed to load usage data', - 'failed to load usage data' -] diff --git a/src/main/rate-limits/claude-pty-usage-parser.ts b/src/main/rate-limits/claude-pty-usage-parser.ts deleted file mode 100644 index 3328d9471600..000000000000 --- a/src/main/rate-limits/claude-pty-usage-parser.ts +++ /dev/null @@ -1,166 +0,0 @@ -import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' -import { extractClaudePtyResetMetadata } from './claude-pty-reset-parser' - -// Why: these patterns match the Claude CLI's /usage TUI panel output. -// "Current session" shows a percent like "62% used" or "62% left". -// Weekly labels have varied between "Current week" and "Weekly limits". -const SESSION_RE = /current\s*session/i -const WEEKLY_RE = /(?:current\s*week|weekly\s*(?:limits?|usage|rate\s*limits?)|7\s*[- ]?\s*day)/i -const FABLE_WORD_RE = /\bfable\b/i -const FABLE_LABEL_RE = /^\s*fable\s*$/i -// Why: derive from WEEKLY_RE so a future weekly-wording change stays in one place -// instead of silently reopening the Fable-weekly parsing gap this fix closed. -const FABLE_WEEKLY_LABEL_RE = new RegExp( - `${WEEKLY_RE.source}\\s*(?:\\([^)]*\\bfable\\b[^)]*\\)|[-:]?\\s*\\bfable\\b)`, - 'i' -) -const PERCENT_RE = /(\d{1,3})(?:\.\d+)?\s*%\s*(used|consumed|left|remaining|available)/i -const ESC = String.fromCharCode(27) -const BEL = String.fromCharCode(7) -const OSC_SEQUENCE_RE = new RegExp(`${ESC}\\][^${BEL}]*(?:${BEL}|${ESC}\\\\)`, 'g') -const CSI_SEQUENCE_RE = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, 'g') - -export function stripTerminalControlSequences(output: string): string { - return output.replace(OSC_SEQUENCE_RE, '').replace(CSI_SEQUENCE_RE, '') -} - -/** - * Extract percent-left from lines following a label match. - * Scans up to 12 lines after the label to find the associated percent. - */ -function matchesWeeklyLabel(line: string): boolean { - return WEEKLY_RE.test(line) && !FABLE_WORD_RE.test(line) -} - -function matchesFableBoundary(line: string): boolean { - return FABLE_LABEL_RE.test(line) || (FABLE_WORD_RE.test(line) && WEEKLY_RE.test(line)) -} - -function matchesFableUsageLabel(line: string): boolean { - // Why: broad Fable-weekly copy should stop nearby scans, but only a real - // Fable usage heading should produce the distinct Fable meter. - return FABLE_LABEL_RE.test(line) || FABLE_WEEKLY_LABEL_RE.test(line) -} - -function isSectionLabel(line: string): boolean { - return SESSION_RE.test(line) || matchesWeeklyLabel(line) || matchesFableBoundary(line) -} - -function extractPercentAfterLabel( - lines: string[], - matchesLabel: (line: string) => boolean -): number | null { - for (let i = 0; i < lines.length; i++) { - if (!matchesLabel(lines[i])) { - continue - } - // Scan next 12 lines for a percent - for (let j = i; j < Math.min(i + 12, lines.length); j++) { - if (j > i && isSectionLabel(lines[j])) { - break - } - const m = PERCENT_RE.exec(lines[j]) - if (m) { - const pct = Number.parseFloat(m[1]) - const word = m[2].toLowerCase() - const isUsed = word === 'used' || word === 'consumed' - return isUsed ? pct : 100 - pct - } - } - } - return null -} - -export function parseClaudePtyUsage(output: string): { - session: RateLimitWindow | null - weekly: RateLimitWindow | null - fableWeekly: RateLimitWindow | null -} { - const lines = output.split(/\r\n|\n|\r/) - - const sessionPct = extractPercentAfterLabel(lines, (line) => SESSION_RE.test(line)) - const weeklyPct = extractPercentAfterLabel(lines, matchesWeeklyLabel) - const fableWeeklyPct = extractPercentAfterLabel(lines, matchesFableUsageLabel) - const sessionReset = extractClaudePtyResetMetadata( - lines, - (line) => SESSION_RE.test(line), - isSectionLabel - ) - const weeklyReset = extractClaudePtyResetMetadata(lines, matchesWeeklyLabel, isSectionLabel) - const fableWeeklyReset = extractClaudePtyResetMetadata( - lines, - matchesFableUsageLabel, - isSectionLabel - ) - - const session: RateLimitWindow | null = - sessionPct !== null - ? { - usedPercent: Math.min(100, Math.max(0, sessionPct)), - windowMinutes: 300, - resetsAt: sessionReset.resetsAt, - resetDescription: sessionReset.resetDescription - } - : null - - const weekly: RateLimitWindow | null = - weeklyPct !== null - ? { - usedPercent: Math.min(100, Math.max(0, weeklyPct)), - windowMinutes: 10080, - resetsAt: weeklyReset.resetsAt, - resetDescription: weeklyReset.resetDescription - } - : null - - const fableWeekly: RateLimitWindow | null = - fableWeeklyPct !== null - ? { - usedPercent: Math.min(100, Math.max(0, fableWeeklyPct)), - windowMinutes: 10080, - resetsAt: fableWeeklyReset.resetsAt, - resetDescription: fableWeeklyReset.resetDescription - } - : null - - return { session, weekly, fableWeekly } -} - -const RATE_LIMITED_RE = /rate limited\.?\s+please try again later/i -const LOAD_FAILED_RE = /failed to load usage data/i -const CLAUDE_21_USAGE_TABS_RE = /settings?\s+status?\s+config\s+usage\s+stats/i -const CLAUDE_21_SESSION_STATS_RE = /total\s*cost|total\s*duration|usage:\s*\d+\s*input/i - -export function isClaude21UsagePanel(output: string): boolean { - return CLAUDE_21_USAGE_TABS_RE.test(output) || CLAUDE_21_SESSION_STATS_RE.test(output) -} - -export function describeClaudeUsageFailure(output: string): string { - if (RATE_LIMITED_RE.test(output)) { - return 'Claude usage is rate limited right now.' - } - - if (LOAD_FAILED_RE.test(output)) { - return 'Claude usage is unavailable right now.' - } - - if (CLAUDE_21_USAGE_TABS_RE.test(output) || CLAUDE_21_SESSION_STATS_RE.test(output)) { - return 'Claude plan usage is unavailable for this Claude CLI session.' - } - - // Why: parser failures are an implementation detail of Orca's PTY fallback. - // The UI should explain the user-visible outcome, not leak internal parsing - // mechanics that the user cannot act on. - return 'Claude usage is unavailable right now.' -} - -export function abortedClaudeUsageResult(): ProviderRateLimits { - return { - provider: 'claude', - session: null, - weekly: null, - updatedAt: Date.now(), - error: 'Rate-limit fetch aborted', - status: 'error' - } -} diff --git a/src/main/rate-limits/claude-pty.test.ts b/src/main/rate-limits/claude-pty.test.ts deleted file mode 100644 index f2fd976821b6..000000000000 --- a/src/main/rate-limits/claude-pty.test.ts +++ /dev/null @@ -1,554 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { resolveClaudeCommandMock, spawnMock } = vi.hoisted(() => ({ - resolveClaudeCommandMock: vi.fn(), - spawnMock: vi.fn() -})) - -vi.mock('../codex-cli/command', () => ({ - resolveClaudeCommand: resolveClaudeCommandMock -})) - -vi.mock('node-pty', () => ({ - spawn: spawnMock -})) - -import { fetchViaPty } from './claude-pty' -import { getActiveHiddenRateLimitPtyCount } from './hidden-pty-cleanup' - -function makeDisposable() { - return { dispose: vi.fn() } -} - -type MockTerm = { - onData: ReturnType - onExit: ReturnType - write: ReturnType - kill: ReturnType -} - -function makeMockTerm(): MockTerm & { - emitData: (data: string) => void - emitExit: () => void -} { - let dataHandler: ((data: string) => void) | null = null - let exitHandler: (() => void) | null = null - return { - onData: vi.fn((handler: (data: string) => void) => { - dataHandler = handler - return makeDisposable() - }), - onExit: vi.fn((handler: () => void) => { - exitHandler = handler - return makeDisposable() - }), - write: vi.fn(), - kill: vi.fn(), - emitData: (data: string) => dataHandler?.(data), - emitExit: () => exitHandler?.() - } -} - -describe('fetchViaPty', () => { - beforeEach(() => { - vi.useFakeTimers() - vi.clearAllMocks() - resolveClaudeCommandMock.mockReturnValue('claude') - }) - - it('disposes node-pty listeners before killing the hidden PTY on timeout', async () => { - const onDataDisposable = makeDisposable() - const onExitDisposable = makeDisposable() - const killMock = vi.fn() - - spawnMock.mockReturnValue({ - onData: vi.fn(() => onDataDisposable), - onExit: vi.fn(() => onExitDisposable), - write: vi.fn(), - kill: killMock - }) - - const resultPromise = fetchViaPty() - await vi.advanceTimersByTimeAsync(25_000) - await resultPromise - - expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - killMock.mock.invocationCallOrder[0] - ) - expect(onExitDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - killMock.mock.invocationCallOrder[0] - ) - }) - - it('injects the configured proxy into the hidden PTY spawn env', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty({ - networkProxySettings: { httpProxyUrl: 'http://127.0.0.1:7890' } - }) - await vi.advanceTimersByTimeAsync(0) - - expect(spawnMock).toHaveBeenCalled() - const spawnEnv = spawnMock.mock.calls[0]?.[2]?.env as Record - expect(spawnEnv.HTTPS_PROXY).toBe('http://127.0.0.1:7890') - expect(spawnEnv.HTTP_PROXY).toBe('http://127.0.0.1:7890') - - term.emitExit() - await resultPromise - }) - - it('spawns the hidden usage PTY in a bounded non-root cwd', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - await vi.advanceTimersByTimeAsync(0) - - const spawnCwd = spawnMock.mock.calls[0]?.[2]?.cwd as string - expect(spawnCwd).toContain('rate-limit-pty-cwd') - expect(spawnCwd).not.toBe('/') - expect(spawnCwd).not.toMatch(/^[A-Za-z]:(?:[\\/])?$/) - - term.emitExit() - await resultPromise - }) - - it('leaves the spawn env proxy untouched when no proxy is configured', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - const inheritedHttpsProxy = process.env.HTTPS_PROXY - - const resultPromise = fetchViaPty() - await vi.advanceTimersByTimeAsync(0) - - const spawnEnv = spawnMock.mock.calls[0]?.[2]?.env as Record - expect(spawnEnv.HTTPS_PROXY).toBe(inheritedHttpsProxy) - - term.emitExit() - await resultPromise - }) - - it('exports the configured proxy inside the WSL launch command', async () => { - // Why: Windows-side spawn env does not cross into the distro, so the proxy - // must be exported in the bash command for the inner claude to see it. - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty({ - networkProxySettings: { httpProxyUrl: 'http://127.0.0.1:7890' }, - authPreparation: { - configDir: '/home/u/.claude', - runtime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxConfigDir: '/home/u/.claude', - envPatch: { CLAUDE_CONFIG_DIR: '/home/u/.claude' }, - stripAuthEnv: false, - provenance: 'system' - } - }) - await vi.advanceTimersByTimeAsync(0) - - const [spawnFile, spawnArgs] = spawnMock.mock.calls[0] as [string, string[]] - expect(spawnFile).toBe('wsl.exe') - const bashCommand = spawnArgs.at(-1) as string - expect(bashCommand).toContain('mkdir -p "$orca_rate_limit_cwd"') - expect(bashCommand).toContain('cd "$orca_rate_limit_cwd"') - expect(bashCommand).toContain("export HTTPS_PROXY='http://127.0.0.1:7890'") - expect(bashCommand).toContain('exec claude') - - term.emitExit() - await resultPromise - }) - - it('kills and unregisters the hidden PTY when the fetch signal aborts', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - const controller = new AbortController() - const killMock = term.kill - - const resultPromise = fetchViaPty({ signal: controller.signal }) - await vi.advanceTimersByTimeAsync(0) - - expect(getActiveHiddenRateLimitPtyCount()).toBe(1) - - controller.abort() - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - error: 'Rate-limit fetch aborted' - }) - expect(killMock).toHaveBeenCalledTimes(1) - expect(getActiveHiddenRateLimitPtyCount()).toBe(0) - }) - - it('clears the startup delay timer when the hidden PTY exits early', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - await vi.advanceTimersByTimeAsync(0) - expect(spawnMock).toHaveBeenCalled() - expect(vi.getTimerCount()).toBeGreaterThan(0) - - term.emitExit() - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'error' - }) - expect(vi.getTimerCount()).toBe(0) - }) - - it('clears pending settle timers when the hidden PTY exits after usage output starts', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - await vi.advanceTimersByTimeAsync(2_000) - term.emitData('Current session\r12% used\r') - expect(vi.getTimerCount()).toBeGreaterThan(0) - - term.emitExit() - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 12 - } - }) - expect(vi.getTimerCount()).toBe(0) - }) - - it('treats Claude 2.1 tabbed /usage session stats as rendered but unavailable', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - expect(term.write).toHaveBeenCalledWith('/usage\r') - - term.emitData(` - Settings Status Config Usage Stats - - Session - Total cost: $0.0000 - Usage: 0 input, 0 output, 0 cache read, 0 cache write - `) - - await vi.advanceTimersByTimeAsync(8_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'error', - session: null, - weekly: null, - error: 'Claude plan usage is unavailable for this Claude CLI session.' - }) - expect(term.write).not.toHaveBeenCalledWith('\x1b[D\x1b[D') - }) - - it('keeps waiting for plan windows after the Claude 2.1 usage shell renders', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Settings Status Config Usage Stats - Session - Total cost: $0.0000 - `) - - await vi.advanceTimersByTimeAsync(1_000) - term.emitData('Current session\r12% used\rResets 4:00pm\rCurrent week (all models)\r34% used\r') - await vi.advanceTimersByTimeAsync(2_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 12, - resetDescription: '4:00pm' - }, - weekly: { - usedPercent: 34 - }, - error: null - }) - }) - - it('parses the newer Claude weekly limits wording for Fable usage', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - vi.setSystemTime(new Date(2026, 6, 3, 20, 0)) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Plan usage limits - - Current session - 18% remaining - Resets in 2h 10m - - Weekly limits - Fable - 42% consumed - Resets in 3d 2h - `) - await vi.advanceTimersByTimeAsync(2_000) - - const result = await resultPromise - - expect(result).toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 82, - resetsAt: Date.now() + 2 * 60 * 60_000 + 10 * 60_000, - resetDescription: '2h 10m' - }, - weekly: null, - fableWeekly: { - usedPercent: 42, - resetsAt: Date.now() + 3 * 24 * 60 * 60_000 + 2 * 60 * 60_000, - resetDescription: '3d 2h' - }, - error: null - }) - }) - - it('parses generic weekly and Fable weekly limits as separate windows', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Plan usage limits - - Current session - 18% remaining - Resets in 2h 10m - - Current week (all models) - 84% left - Resets in 5d 4h - - Fable - 42% consumed - Resets in 3d 2h - `) - await vi.advanceTimersByTimeAsync(2_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 82, - resetDescription: '2h 10m' - }, - weekly: { - usedPercent: 16, - resetDescription: '5d 4h' - }, - fableWeekly: { - usedPercent: 42, - resetDescription: '3d 2h' - }, - error: null - }) - }) - - it('parses Claude current-week Fable usage as a distinct weekly window', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - vi.setSystemTime(new Date(2026, 6, 3, 20, 0)) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Plan usage limits - - Current session - 8% used - Resets 3:39am - - Current week (all models) - 33% used - Resets Jul 10 at 12:59pm - - Current week (Fable) - 62% used - Resets Jul10at12:59pm(America/Los_Angeles) - `) - await vi.advanceTimersByTimeAsync(2_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 8, - resetDescription: '3:39am' - }, - weekly: { - usedPercent: 33, - resetsAt: new Date(2026, 6, 10, 12, 59).getTime(), - resetDescription: 'Jul 10 at 12:59pm' - }, - fableWeekly: { - usedPercent: 62, - resetsAt: Date.parse('2026-07-10T19:59:00.000Z'), - resetDescription: 'Jul 10 at 12:59pm (America/Los_Angeles' - }, - error: null - }) - }) - - it('does not let an incomplete Fable section consume later usage sections', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Plan usage limits - - Fable - Usage unavailable - - Current session - 12% used - - Current week (all models) - 84% left - `) - await vi.advanceTimersByTimeAsync(2_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 12 - }, - weekly: { - usedPercent: 16 - }, - fableWeekly: null, - error: null - }) - }) - - it('does not treat inline Fable weekly text as a parsed usage label', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Plan usage limits - - Current session - Fable weekly usage - 42% consumed - - Current week (all models) - 84% left - `) - await vi.advanceTimersByTimeAsync(2_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: null, - weekly: { - usedPercent: 16 - }, - fableWeekly: null, - error: null - }) - }) - - it('keeps waiting after a bare Fable heading until another usage section renders', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - let settled = false - - const resultPromise = fetchViaPty().finally(() => { - settled = true - }) - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Plan usage limits - - Fable - `) - await vi.advanceTimersByTimeAsync(2_000) - - expect(settled).toBe(false) - - term.emitData(` - 42% consumed - - Current session - 12% used - `) - await vi.advanceTimersByTimeAsync(2_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 12 - }, - fableWeekly: { - usedPercent: 42 - }, - error: null - }) - }) - - it('parses 7-day weekly labels without the old Current week heading', async () => { - const term = makeMockTerm() - spawnMock.mockReturnValue(term) - - const resultPromise = fetchViaPty() - - await vi.advanceTimersByTimeAsync(2_000) - term.emitData(` - Usage - - Current session - 12% used - - 7-day - 84% left - Resets Wed at 9:05 PM - `) - await vi.advanceTimersByTimeAsync(2_000) - - await expect(resultPromise).resolves.toMatchObject({ - provider: 'claude', - status: 'ok', - session: { - usedPercent: 12 - }, - weekly: { - usedPercent: 16, - resetDescription: 'Wed at 9:05 PM' - }, - error: null - }) - }) -}) diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts deleted file mode 100644 index cbaedbd17f9d..000000000000 --- a/src/main/rate-limits/claude-pty.ts +++ /dev/null @@ -1,354 +0,0 @@ -import type { ProviderRateLimits } from '../../shared/rate-limit-types' -import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy' -import { resolveClaudeCommand } from '../codex-cli/command' -// Why: import from the shared module, not the codex-cli re-export, so a test that -// mocks '../codex-cli/command' does not have to restate this pure helper. -import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' -import { applyClaudeEnvPatch } from '../claude-accounts/environment' -import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' -import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup' -import { - getHiddenRateLimitWslCwdSetupCommands, - resolveHiddenRateLimitPtyCwd -} from './hidden-rate-limit-pty-cwd' -import { - abortedClaudeUsageResult, - describeClaudeUsageFailure, - isClaude21UsagePanel, - parseClaudePtyUsage, - stripTerminalControlSequences -} from './claude-pty-usage-parser' -import { quoteHiddenRateLimitShellValue } from './hidden-rate-limit-shell' -import { CLAUDE_USAGE_STOP_SUBSTRINGS } from './claude-pty-stop-markers' - -const PTY_TIMEOUT_MS = 25_000 -const MAX_OUTPUT_LENGTH = 100_000 // 100KB buffer limit - -// --------------------------------------------------------------------------- -// PTY fallback — spawn interactive `claude`, send `/usage`, parse the TUI -// --------------------------------------------------------------------------- - -// Why: prompt detection is unreliable because the Claude CLI v2.x renders -// a status bar and TUI elements that push the `❯` prompt out of any -// reasonable detection window. Instead we wait a fixed 2s after spawning -// for the CLI to initialize, then send `/usage\r` directly. Command -// palette prompts ("Show plan usage limits") are auto-confirmed with Enter. -const COMMAND_PALETTE_RE = /show plan|usage limits/i -const TRUST_PROMPT_RE = /do you trust|trust the files|safety check/i -const STARTUP_DELAY_MS = 2_000 -const SETTLE_AFTER_STOP_MS = 2_000 -const SETTLE_AFTER_CLAUDE_21_USAGE_MS = 8_000 - -export async function fetchViaPty(options?: { - authPreparation?: ClaudeRuntimeAuthPreparation - networkProxySettings?: NetworkProxySettings - signal?: AbortSignal -}): Promise { - if (options?.signal?.aborted) { - return abortedClaudeUsageResult() - } - const pty = await import('node-pty') - if (options?.signal?.aborted) { - return abortedClaudeUsageResult() - } - - return new Promise((resolve) => { - let output = '' - let resolved = false - let sentUsage = false - let stopDetected = false - let claude21UsageDetected = false - let startupDelayTimer: ReturnType | null = null - let stopSettleTimer: ReturnType | null = null - let claude21UsageSettleTimer: ReturnType | null = null - - const claudeCommand = resolveClaudeCommand() - - // Why: node-pty cannot spawn .cmd/.bat batch scripts directly on Windows — - // those need cmd.exe as an interpreter. Always route through cmd.exe on win32 - // and ensure the command path is properly quoted if it contains spaces. - const isWin32 = process.platform === 'win32' - const spawnEnv = applyClaudeEnvPatch( - { ...process.env, TERM: 'xterm-256color' } as Record, - options?.authPreparation?.envPatch ?? {}, - { stripAuthEnv: options?.authPreparation?.stripAuthEnv ?? false } - ) - // Why: this hidden usage PTY spawns `claude` directly, not the user's shell - // wrapper, so without the configured proxy it would reach api.anthropic.com - // from the app's own IP — bypassing the proxy the user set for Claude and - // risking rate-limit/geo signals on the account. Falls back to {} when unset. - const proxyEnv = buildConfiguredProxyEnv(options?.networkProxySettings) - Object.assign(spawnEnv, proxyEnv) - const authPreparation = options?.authPreparation - const wslConfig = - authPreparation?.runtime === 'wsl' && - authPreparation.wslDistro && - authPreparation.wslLinuxConfigDir - ? { - distro: authPreparation.wslDistro, - linuxConfigDir: authPreparation.wslLinuxConfigDir - } - : null - const spawnFile = wslConfig ? 'wsl.exe' : isWin32 ? 'cmd.exe' : claudeCommand - const spawnArgs = wslConfig - ? [ - '-d', - wslConfig.distro, - '--exec', - 'bash', - '-lc', - // Why: Windows-side env does not cross into the distro without WSLENV, - // so export the configured proxy inside the command for the inner claude. - [ - // Why: hidden usage probes must not inherit a root-like WSL cwd; - // keep Claude discovery bounded to a tiny temp directory. - ...getHiddenRateLimitWslCwdSetupCommands(), - `export CLAUDE_CONFIG_DIR=${quoteHiddenRateLimitShellValue(wslConfig.linuxConfigDir)}`, - ...Object.entries(proxyEnv).map( - ([key, value]) => `export ${key}=${quoteHiddenRateLimitShellValue(value)}` - ), - 'exec claude' - ].join(' && ') - ] - : isWin32 - ? ['/c', `"${claudeCommand}"`] - : [] - - const term = pty.spawn(spawnFile, spawnArgs, { - name: 'xterm-256color', - cols: 120, - rows: 40, - // Why: hidden usage PTYs must not inherit the process cwd (e.g. / or a - // drive root), which can trigger unbounded file discovery. - cwd: resolveHiddenRateLimitPtyCwd(), - env: withCliRuntimeOnPath(claudeCommand, spawnEnv) - }) - const termDisposables: { dispose: () => void }[] = [registerHiddenRateLimitPty(term)] - let enterInterval: ReturnType | null = null - let timeout: ReturnType | null = null - - function clearFollowupTimers(): void { - if (startupDelayTimer) { - clearTimeout(startupDelayTimer) - startupDelayTimer = null - } - if (stopSettleTimer) { - clearTimeout(stopSettleTimer) - stopSettleTimer = null - } - if (claude21UsageSettleTimer) { - clearTimeout(claude21UsageSettleTimer) - claude21UsageSettleTimer = null - } - if (enterInterval) { - clearInterval(enterInterval) - enterInterval = null - } - } - - function settleAborted(): void { - if (resolved) { - return - } - resolved = true - if (timeout) { - clearTimeout(timeout) - timeout = null - } - clearFollowupTimers() - cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) - resolve(abortedClaudeUsageResult()) - } - - if (options?.signal) { - if (options.signal.aborted) { - settleAborted() - return - } - options.signal.addEventListener('abort', settleAborted, { once: true }) - termDisposables.push({ - dispose: () => options.signal?.removeEventListener('abort', settleAborted) - }) - } - - timeout = setTimeout(() => { - if (!resolved) { - resolved = true - clearFollowupTimers() - cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) - // Even on timeout, try to parse whatever we collected - const clean = stripTerminalControlSequences(output) - const { session, weekly, fableWeekly } = parseClaudePtyUsage(clean) - if (session || weekly || fableWeekly) { - resolve({ - provider: 'claude', - session, - weekly, - fableWeekly, - updatedAt: Date.now(), - error: null, - status: 'ok' - }) - } else { - resolve({ - provider: 'claude', - session: null, - weekly: null, - updatedAt: Date.now(), - error: withMacTailscaleDnsHint( - isClaude21UsagePanel(clean) - ? describeClaudeUsageFailure(clean) - : 'PTY timeout — /usage panel did not render', - clean - ), - status: 'error' - }) - } - } - }, PTY_TIMEOUT_MS) - - // Why: the Claude TUI may have scrollable panels or prompts. - // Sending Enter every 0.8s advances through them. - function startEnterPresses(): void { - if (enterInterval) { - return - } - enterInterval = setInterval(() => { - if (!resolved && !stopDetected) { - term.write('\r') - } - }, 800) - } - - function finalize(): void { - if (resolved) { - return - } - resolved = true - if (timeout) { - clearTimeout(timeout) - timeout = null - } - clearFollowupTimers() - cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) - - const clean = stripTerminalControlSequences(output) - const { session, weekly, fableWeekly } = parseClaudePtyUsage(clean) - - if (!session && !weekly && !fableWeekly) { - resolve({ - provider: 'claude', - session: null, - weekly: null, - updatedAt: Date.now(), - error: withMacTailscaleDnsHint(describeClaudeUsageFailure(clean), clean), - status: 'error' - }) - } else { - resolve({ - provider: 'claude', - session, - weekly, - fableWeekly, - updatedAt: Date.now(), - error: null, - status: 'ok' - }) - } - } - - // Why: wait 2s for the CLI to initialize, then send `/usage\r` - // directly without detecting the prompt character (see comment above). - startupDelayTimer = setTimeout(() => { - startupDelayTimer = null - if (resolved) { - return - } - sentUsage = true - term.write('/usage\r') - startEnterPresses() - }, STARTUP_DELAY_MS) - - const onDataDisposable = term.onData((data) => { - output += data - // Why: prevent memory exhaustion if the CLI process floods output - if (output.length > MAX_OUTPUT_LENGTH) { - output = output.slice(-MAX_OUTPUT_LENGTH) - } - - const cleanChunk = stripTerminalControlSequences(data) - - // Why: the Claude CLI may prompt for first-run setup (trust files, - // workspace directory). Auto-accept so we can reach /usage. - if (TRUST_PROMPT_RE.test(cleanChunk)) { - term.write('y\r') - return - } - - // Why: Claude CLI v2.x may show a command palette when `/usage` is - // entered, listing options like "Show plan usage limits". Auto-confirm - // by sending Enter when these prompts appear. - if (sentUsage && COMMAND_PALETTE_RE.test(cleanChunk)) { - term.write('\r') - } - - // Check if we've hit a stop substring indicating the panel rendered - if (sentUsage && !stopDetected) { - const clean = stripTerminalControlSequences(output) - if (!claude21UsageDetected && isClaude21UsagePanel(clean)) { - claude21UsageDetected = true - if (enterInterval) { - clearInterval(enterInterval) - enterInterval = null - } - // Why: Claude 2.1 may render session stats without subscription - // plan windows. Give async usage loading a grace period, then finish - // with a user-facing unavailable state instead of a false PTY timeout. - claude21UsageSettleTimer = setTimeout(finalize, SETTLE_AFTER_CLAUDE_21_USAGE_MS) - } - for (const sub of CLAUDE_USAGE_STOP_SUBSTRINGS) { - if (clean.includes(sub)) { - stopDetected = true - // Why: 2.0s settle time after detecting the stop substring - // allows the full panel to finish rendering. - stopSettleTimer = setTimeout(finalize, SETTLE_AFTER_STOP_MS) - break - } - } - } - }) - if (onDataDisposable) { - termDisposables.push(onDataDisposable) - } - - const onExitDisposable = term.onExit(() => { - cleanupHiddenRateLimitPty(term, termDisposables, { kill: false }) - clearFollowupTimers() - if (!resolved) { - resolved = true - if (timeout) { - clearTimeout(timeout) - timeout = null - } - const clean = stripTerminalControlSequences(output) - const { session, weekly, fableWeekly } = parseClaudePtyUsage(clean) - resolve({ - provider: 'claude', - session, - weekly, - fableWeekly, - updatedAt: Date.now(), - error: - session || weekly || fableWeekly - ? null - : withMacTailscaleDnsHint('CLI exited before /usage rendered', clean), - status: session || weekly || fableWeekly ? 'ok' : 'error' - }) - } - }) - if (onExitDisposable) { - termDisposables.push(onExitDisposable) - } - }) -} diff --git a/src/main/rate-limits/claude-usage-error-classification.test.ts b/src/main/rate-limits/claude-usage-error-classification.test.ts index 9371479cfba5..b77c6271d1a0 100644 --- a/src/main/rate-limits/claude-usage-error-classification.test.ts +++ b/src/main/rate-limits/claude-usage-error-classification.test.ts @@ -12,7 +12,6 @@ describe('classifyClaudeOAuthUsageError', () => { ).toMatchObject({ failureKind: 'stale-token', shouldAttemptDelegatedRefresh: true, - shouldAttemptCliFallback: true, terminal: false }) }) @@ -23,7 +22,6 @@ describe('classifyClaudeOAuthUsageError', () => { ).toMatchObject({ failureKind: 'rate-limited', shouldAttemptDelegatedRefresh: false, - shouldAttemptCliFallback: false, terminal: true }) }) @@ -42,7 +40,6 @@ describe('classifyClaudeOAuthUsageError', () => { it('allows CLI fallback for network-shaped failures', () => { expect(classifyClaudeOAuthUsageError(new Error('fetch failed: ENOTFOUND'))).toMatchObject({ failureKind: 'network', - shouldAttemptCliFallback: true, shouldAttemptDelegatedRefresh: false }) }) @@ -52,8 +49,7 @@ describe('classifyClaudeCredentialAbsence', () => { it('classifies refresh-only credentials as repairable', () => { expect(classifyClaudeCredentialAbsence({ hasRefreshableCredentials: true })).toMatchObject({ failureKind: 'refreshable-credentials-without-token', - shouldAttemptDelegatedRefresh: true, - shouldAttemptCliFallback: true + shouldAttemptDelegatedRefresh: true }) }) @@ -65,7 +61,6 @@ describe('classifyClaudeCredentialAbsence', () => { }) ).toMatchObject({ failureKind: 'keychain-unavailable', - shouldAttemptCliFallback: true, shouldAttemptDelegatedRefresh: false }) }) diff --git a/src/main/rate-limits/claude-usage-error-classification.ts b/src/main/rate-limits/claude-usage-error-classification.ts index 71c88c242bd8..7e60f09e95e1 100644 --- a/src/main/rate-limits/claude-usage-error-classification.ts +++ b/src/main/rate-limits/claude-usage-error-classification.ts @@ -3,7 +3,6 @@ import { OAuthUsageError } from './claude-oauth-usage-error' export type ClaudeUsageErrorClassification = { failureKind: UsageRateLimitFailureKind - shouldAttemptCliFallback: boolean shouldAttemptDelegatedRefresh: boolean terminal: boolean } @@ -22,21 +21,21 @@ export function classifyClaudeOAuthUsageError(error: unknown): ClaudeUsageErrorC : recoverableAuth('stale-token') } if (error.status >= 500) { - return fallbackOnly('server') + return transientFailure('server') } return terminal('usage-unavailable') } if (error instanceof SyntaxError) { - return fallbackOnly('parse') + return transientFailure('parse') } const message = error instanceof Error ? error.message : String(error) if (/\babort|network|econn|enotfound|etimedout|fetch failed|dns\b/i.test(message)) { - return fallbackOnly('network') + return transientFailure('network') } - return fallbackOnly('unknown') + return transientFailure('unknown') } export function classifyClaudeCredentialAbsence(input: { @@ -48,7 +47,7 @@ export function classifyClaudeCredentialAbsence(input: { return terminal('deferred-by-live-session') } if (input.keychainUnavailable) { - return fallbackOnly('keychain-unavailable') + return transientFailure('keychain-unavailable') } if (input.hasRefreshableCredentials) { return recoverableAuth('refreshable-credentials-without-token') @@ -59,16 +58,14 @@ export function classifyClaudeCredentialAbsence(input: { function recoverableAuth(failureKind: UsageRateLimitFailureKind): ClaudeUsageErrorClassification { return { failureKind, - shouldAttemptCliFallback: true, shouldAttemptDelegatedRefresh: true, terminal: false } } -function fallbackOnly(failureKind: UsageRateLimitFailureKind): ClaudeUsageErrorClassification { +function transientFailure(failureKind: UsageRateLimitFailureKind): ClaudeUsageErrorClassification { return { failureKind, - shouldAttemptCliFallback: true, shouldAttemptDelegatedRefresh: false, terminal: false } @@ -77,7 +74,6 @@ function fallbackOnly(failureKind: UsageRateLimitFailureKind): ClaudeUsageErrorC function terminal(failureKind: UsageRateLimitFailureKind): ClaudeUsageErrorClassification { return { failureKind, - shouldAttemptCliFallback: false, shouldAttemptDelegatedRefresh: false, terminal: true } diff --git a/src/main/rate-limits/claude-usage-fetch-options.ts b/src/main/rate-limits/claude-usage-fetch-options.ts index e2d0f5d9c009..7f84ea79f183 100644 --- a/src/main/rate-limits/claude-usage-fetch-options.ts +++ b/src/main/rate-limits/claude-usage-fetch-options.ts @@ -3,14 +3,13 @@ import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-au export type ClaudeRateLimitFetchOptions = { authPreparation?: ClaudeRuntimeAuthPreparation - allowPtyFallback?: boolean - allowUsagePanelSupplement?: boolean + /** Let the account's own Claude CLI refresh an expired login; off unless the caller opts in. */ + allowCliLoginRefresh?: boolean networkProxySettings?: NetworkProxySettings signal?: AbortSignal } export type ClaudeManagedAccountUsageOptions = { - allowUsagePanelSupplement?: boolean networkProxySettings?: NetworkProxySettings signal?: AbortSignal } diff --git a/src/main/rate-limits/claude-usage-refresh-plan.test.ts b/src/main/rate-limits/claude-usage-refresh-plan.test.ts deleted file mode 100644 index dced9e7815c7..000000000000 --- a/src/main/rate-limits/claude-usage-refresh-plan.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { resolveClaudeUsageRefreshPlan } from './claude-usage-refresh-plan' - -describe('resolveClaudeUsageRefreshPlan', () => { - it('uses OAuth then CLI app auto ordering when CLI fallback is available', () => { - expect(resolveClaudeUsageRefreshPlan({ allowCliFallback: true }).steps).toEqual([ - { source: 'oauth', reason: 'app-auto-preferred-oauth' }, - { source: 'cli', reason: 'app-auto-fallback-cli' } - ]) - }) - - it('keeps web deferred and internal-only for the first parity pass', () => { - expect(resolveClaudeUsageRefreshPlan({ allowCliFallback: true }).webDeferred).toBe(true) - }) - - it('omits CLI fallback for unresolved WSL Claude config', () => { - expect( - resolveClaudeUsageRefreshPlan({ - allowCliFallback: true, - authPreparation: { - configDir: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.claude', - runtime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxConfigDir: null, - envPatch: {}, - stripAuthEnv: true, - provenance: 'wsl:Ubuntu:system' - } - }).steps - ).toEqual([{ source: 'oauth', reason: 'app-auto-preferred-oauth' }]) - }) -}) diff --git a/src/main/rate-limits/claude-usage-refresh-plan.ts b/src/main/rate-limits/claude-usage-refresh-plan.ts deleted file mode 100644 index 06144a15715b..000000000000 --- a/src/main/rate-limits/claude-usage-refresh-plan.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { UsageRateLimitSource } from '../../shared/rate-limit-types' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' - -export type ClaudeUsageRefreshStep = { - source: UsageRateLimitSource - reason: 'app-auto-preferred-oauth' | 'app-auto-fallback-cli' | 'future-web-fallback-deferred' -} - -export type ClaudeUsageRefreshPlanInput = { - authPreparation?: ClaudeRuntimeAuthPreparation - allowCliFallback: boolean -} - -export type ClaudeUsageRefreshPlan = { - steps: ClaudeUsageRefreshStep[] - webDeferred: boolean -} - -export function resolveClaudeUsageRefreshPlan( - input: ClaudeUsageRefreshPlanInput -): ClaudeUsageRefreshPlan { - const steps: ClaudeUsageRefreshStep[] = [ - { - source: 'oauth', - reason: 'app-auto-preferred-oauth' - } - ] - - if (input.allowCliFallback && isCliPlausiblyAvailable(input.authPreparation)) { - steps.push({ - source: 'cli', - reason: 'app-auto-fallback-cli' - }) - } - - return { - steps, - webDeferred: true - } -} - -function isCliPlausiblyAvailable(authPreparation?: ClaudeRuntimeAuthPreparation): boolean { - if (authPreparation?.runtime === 'wsl') { - return Boolean(authPreparation.wslDistro && authPreparation.wslLinuxConfigDir) - } - return true -} diff --git a/src/main/rate-limits/claude-usage-result.ts b/src/main/rate-limits/claude-usage-result.ts index aea522e673bf..eb74c62cb1d9 100644 --- a/src/main/rate-limits/claude-usage-result.ts +++ b/src/main/rate-limits/claude-usage-result.ts @@ -101,31 +101,20 @@ export function warnClaudeUsageFetchFailure( }) } -export function mergeClaudeUsageWindows( - primary: ProviderRateLimits, - supplement: ProviderRateLimits | null -): ProviderRateLimits { - if (!supplement) { - return primary - } - return { - ...primary, - session: primary.session ?? supplement.session, - weekly: primary.weekly ?? supplement.weekly, - fableWeekly: primary.fableWeekly ?? supplement.fableWeekly ?? null - } -} - -export function canSupplementClaudeOAuthUsage(input: { - oauthLimits: ProviderRateLimits +export function claudeOAuthUsageSuccess(input: { + limits: ProviderRateLimits + oauthCredentials: ClaudeOAuthCredentialReadResult + attempts: ClaudeUsageAttemptState authPreparation?: ClaudeRuntimeAuthPreparation - allowUsagePanelSupplement: boolean -}): boolean { - return Boolean( - input.allowUsagePanelSupplement && - !input.authPreparation?.managedRefreshDeferredByLivePty && - !input.oauthLimits.fableWeekly && - (input.oauthLimits.session || input.oauthLimits.weekly) +}): ProviderRateLimits { + return withClaudeUsageMetadata( + input.limits, + metadataForClaudeUsageAttempt({ + attemptedSources: input.attempts.attemptedSources, + oauthCredentials: input.oauthCredentials, + authPreparation: input.authPreparation, + source: 'oauth' + }) ) } diff --git a/src/main/rate-limits/hidden-pty-cleanup.test.ts b/src/main/rate-limits/hidden-pty-cleanup.test.ts deleted file mode 100644 index 43e8453fa0fd..000000000000 --- a/src/main/rate-limits/hidden-pty-cleanup.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - cleanupHiddenRateLimitPty, - getActiveHiddenRateLimitPtyCount, - registerHiddenRateLimitPty -} from './hidden-pty-cleanup' - -function setPlatform(platform: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { - configurable: true, - value: platform - }) -} - -describe('cleanupHiddenRateLimitPty', () => { - const originalPlatform = process.platform - - afterEach(() => { - setPlatform(originalPlatform) - vi.clearAllMocks() - }) - - it('disposes listeners, kills the child, then destroys the PTY fd on POSIX', () => { - setPlatform('darwin') - const dataDisposable = { dispose: vi.fn() } - const exitDisposable = { dispose: vi.fn() } - const killMock = vi.fn() - const term = { - kill: killMock, - destroy: vi.fn() - } - - cleanupHiddenRateLimitPty(term, [dataDisposable, exitDisposable], { kill: true }) - - expect(dataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - killMock.mock.invocationCallOrder[0] - ) - expect(exitDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - killMock.mock.invocationCallOrder[0] - ) - expect(killMock.mock.invocationCallOrder[0]).toBeLessThan( - term.destroy.mock.invocationCallOrder[0] - ) - }) - - it('releases the PTY fd without killing again after natural exit', () => { - setPlatform('darwin') - const killMock = vi.fn() - const term = { - kill: killMock, - destroy: vi.fn() - } - - cleanupHiddenRateLimitPty(term, [], { kill: false }) - - expect(killMock).not.toHaveBeenCalled() - expect(term.destroy).toHaveBeenCalledTimes(1) - }) - - it('neutralizes POSIX destroy-time SIGHUP after the intentional kill', () => { - setPlatform('linux') - const killMock = vi.fn() - const term = { - kill: killMock, - destroy: vi.fn(() => { - term.kill('SIGHUP') - }) - } - - cleanupHiddenRateLimitPty(term, [], { kill: true }) - - expect(killMock).toHaveBeenCalledTimes(1) - expect(killMock).toHaveBeenCalledWith() - }) - - it('does not destroy after an intentional Windows kill because destroy kills again', () => { - setPlatform('win32') - const term = { - kill: vi.fn(), - destroy: vi.fn(() => { - term.kill() - }) - } - - cleanupHiddenRateLimitPty(term, [], { kill: true }) - - expect(term.kill).toHaveBeenCalledTimes(1) - expect(term.destroy).not.toHaveBeenCalled() - }) - - it('destroys a Windows PTY after natural exit so ConPTY cleanup still runs', () => { - setPlatform('win32') - const term = { - kill: vi.fn(), - destroy: vi.fn(() => { - term.kill() - }) - } - - cleanupHiddenRateLimitPty(term, [], { kill: false }) - - expect(term.kill).toHaveBeenCalledTimes(1) - expect(term.destroy).toHaveBeenCalledTimes(1) - }) - - it('removes registered hidden PTYs when cleanup kills them', () => { - setPlatform('darwin') - const term = { - kill: vi.fn(), - destroy: vi.fn() - } - const registration = registerHiddenRateLimitPty(term) - - expect(getActiveHiddenRateLimitPtyCount()).toBe(1) - - cleanupHiddenRateLimitPty(term, [registration], { kill: true }) - - expect(getActiveHiddenRateLimitPtyCount()).toBe(0) - }) - - it('removes registered hidden PTYs after natural exit cleanup', () => { - setPlatform('darwin') - const term = { - kill: vi.fn(), - destroy: vi.fn() - } - const registration = registerHiddenRateLimitPty(term) - - expect(getActiveHiddenRateLimitPtyCount()).toBe(1) - - cleanupHiddenRateLimitPty(term, [registration], { kill: false }) - - expect(getActiveHiddenRateLimitPtyCount()).toBe(0) - }) -}) diff --git a/src/main/rate-limits/hidden-pty-cleanup.ts b/src/main/rate-limits/hidden-pty-cleanup.ts deleted file mode 100644 index 068b937ee844..000000000000 --- a/src/main/rate-limits/hidden-pty-cleanup.ts +++ /dev/null @@ -1,58 +0,0 @@ -type HiddenPty = { - kill: (signal?: string) => void - destroy?: () => void -} - -type Disposable = { - dispose: () => void -} - -const activeHiddenRateLimitPtys = new Set() - -export function registerHiddenRateLimitPty(term: HiddenPty): Disposable { - activeHiddenRateLimitPtys.add(term) - return { - dispose: () => { - activeHiddenRateLimitPtys.delete(term) - } - } -} - -export function getActiveHiddenRateLimitPtyCount(): number { - return activeHiddenRateLimitPtys.size -} - -export function cleanupHiddenRateLimitPty( - term: HiddenPty, - disposables: Disposable[], - options: { kill: boolean } -): void { - for (const disposable of disposables.splice(0)) { - disposable.dispose() - } - - if (options.kill) { - try { - term.kill() - } catch { - /* already exited */ - } - - // Why: node-pty WindowsTerminal.destroy() calls kill() again, which can - // close the same ConPTY handle twice after an intentional termination. - if (process.platform === 'win32') { - return - } - } - - // Why: node-pty destroy releases the master PTY fd; on POSIX, neutralize - // the post-close SIGHUP hook after exit/kill to avoid pid reuse. - if (process.platform !== 'win32') { - term.kill = () => {} - } - try { - term.destroy?.() - } catch { - /* already torn down */ - } -} diff --git a/src/main/rate-limits/service-account-target-selection.test.ts b/src/main/rate-limits/service-account-target-selection.test.ts index 27e93208c1a1..af254325b3ac 100644 --- a/src/main/rate-limits/service-account-target-selection.test.ts +++ b/src/main/rate-limits/service-account-target-selection.test.ts @@ -388,15 +388,14 @@ describe('RateLimitService', () => { wslLinuxConfigDir: '/home/jin/.claude', stripAuthEnv: true }), - allowPtyFallback: true, - allowUsagePanelSupplement: true, + allowCliLoginRefresh: true, signal: expect.any(AbortSignal) }) ) expect(service.getState().claudeTarget).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) }) - it('does not use Claude PTY fallback for system-default usage refreshes', async () => { + it('does not allow a Claude CLI login refresh for system-default usage refreshes', async () => { const service = new RateLimitService() service.setClaudeAuthPreparationResolver(async () => ({ configDir: '/tmp/.claude', @@ -416,14 +415,13 @@ describe('RateLimitService', () => { expect(fetchClaudeRateLimits).toHaveBeenCalledWith( expect.objectContaining({ authPreparation: expect.objectContaining({ provenance: 'system' }), - allowPtyFallback: false, - allowUsagePanelSupplement: true, + allowCliLoginRefresh: false, signal: expect.any(AbortSignal) }) ) }) - it('does not use Claude PTY fallback when Claude auth preparation is unavailable', async () => { + it('does not allow a Claude CLI login refresh when Claude auth preparation is unavailable', async () => { const service = new RateLimitService() vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) @@ -434,14 +432,13 @@ describe('RateLimitService', () => { expect(fetchClaudeRateLimits).toHaveBeenCalledWith( expect.objectContaining({ authPreparation: undefined, - allowPtyFallback: false, - allowUsagePanelSupplement: true, + allowCliLoginRefresh: false, signal: expect.any(AbortSignal) }) ) }) - it('does not use Claude PTY fallback for WSL system-default usage refreshes', async () => { + it('does not allow a Claude CLI login refresh for WSL system-default usage refreshes', async () => { const service = new RateLimitService() service.setClaudeFetchTarget({ runtime: 'wsl', wslDistro: 'Ubuntu' }) service.setClaudeAuthPreparationResolver(async () => ({ @@ -462,8 +459,7 @@ describe('RateLimitService', () => { expect(fetchClaudeRateLimits).toHaveBeenCalledWith( expect.objectContaining({ authPreparation: expect.objectContaining({ provenance: 'wsl:Ubuntu:system' }), - allowPtyFallback: false, - allowUsagePanelSupplement: true, + allowCliLoginRefresh: false, signal: expect.any(AbortSignal) }) ) @@ -579,7 +575,7 @@ describe('RateLimitService', () => { }) expect(fetchClaudeRateLimits).toHaveBeenLastCalledWith( - expect.objectContaining({ allowPtyFallback: true, allowUsagePanelSupplement: true }) + expect.objectContaining({ allowCliLoginRefresh: true }) ) expect(service.getState().inactiveClaudeAccounts).not.toEqual( diff --git a/src/main/rate-limits/service-inactive-account-previews.test.ts b/src/main/rate-limits/service-inactive-account-previews.test.ts index cae71c569e71..8fa2b0bfbd3b 100644 --- a/src/main/rate-limits/service-inactive-account-previews.test.ts +++ b/src/main/rate-limits/service-inactive-account-previews.test.ts @@ -210,23 +210,6 @@ describe('RateLimitService', () => { } }) - it('allows usage-panel Fable supplements for inactive Claude account previews', async () => { - const service = new RateLimitService() - const account = { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' } - service.setInactiveClaudeAccountsResolver(() => [account]) - vi.mocked(fetchManagedAccountUsage).mockResolvedValueOnce(okProvider('claude', 33, Date.now())) - - await service.fetchInactiveClaudeAccountsOnOpen() - - expect(fetchManagedAccountUsage).toHaveBeenCalledWith( - account, - expect.objectContaining({ - allowUsagePanelSupplement: true, - signal: expect.any(AbortSignal) - }) - ) - }) - it('does not start overlapping inactive Claude preview fetches', async () => { const service = new RateLimitService() const accountFetch = deferred() diff --git a/src/main/rate-limits/service-refresh-orchestration.test.ts b/src/main/rate-limits/service-refresh-orchestration.test.ts index e0a1d9c9b042..4ff5da5c9cab 100644 --- a/src/main/rate-limits/service-refresh-orchestration.test.ts +++ b/src/main/rate-limits/service-refresh-orchestration.test.ts @@ -452,8 +452,7 @@ describe('RateLimitService', () => { expect(fetchClaudeRateLimits).toHaveBeenCalledWith( expect.objectContaining({ authPreparation: undefined, - allowPtyFallback: false, - allowUsagePanelSupplement: true, + allowCliLoginRefresh: false, signal: expect.any(AbortSignal) }) ) diff --git a/src/main/rate-limits/service/service-fetch-targets.ts b/src/main/rate-limits/service/service-fetch-targets.ts index a24322a54712..5ace421f8707 100644 --- a/src/main/rate-limits/service/service-fetch-targets.ts +++ b/src/main/rate-limits/service/service-fetch-targets.ts @@ -171,22 +171,17 @@ export abstract class RateLimitServiceFetchTargets extends RateLimitServiceResul return { ...stateBeforeReset, codex: scopedCodex, codexTarget: target } } - protected shouldAllowClaudePtyFallback( + protected shouldAllowClaudeCliLoginRefresh( authPreparation: ClaudeRuntimeAuthPreparation | undefined ): boolean { - // Why: Windows hidden PTY support is less reliable than host/WSL shells. + // Why: Windows managed accounts can live in WSL, which this host-side refresh does not reach. if (process.platform === 'win32') { return false } - // Why: system-default Claude isn't Orca-managed; refresh may read existing OAuth but must not launch Claude and trigger auth/browser flows. + // Why: a system-default login is the user's own; Orca only reads it and never starts Claude for it. return !isSystemDefaultClaudeAuth(authPreparation) } - protected shouldAllowClaudeUsagePanelSupplement(): boolean { - // Why: keep this supplement off on Windows where hidden PTYs are still less reliable. - return process.platform !== 'win32' - } - protected resolveMiniMaxConfig(): MiniMaxResolvedConfig { try { return { diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts index 5395ace5fbff..cf830744bb0e 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -183,8 +183,7 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ ? Promise.resolve(previousState.claude as ProviderRateLimits) : fetchClaudeRateLimits({ authPreparation: claudeAuthPreparation, - allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), - allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), + allowCliLoginRefresh: this.shouldAllowClaudeCliLoginRefresh(claudeAuthPreparation), networkProxySettings: this.networkProxySettingsResolver?.(), signal }), diff --git a/src/main/rate-limits/service/service-inactive-accounts.ts b/src/main/rate-limits/service/service-inactive-accounts.ts index e59d1eeb3a91..312d7f82ad8c 100644 --- a/src/main/rate-limits/service/service-inactive-accounts.ts +++ b/src/main/rate-limits/service/service-inactive-accounts.ts @@ -45,7 +45,6 @@ export abstract class RateLimitServiceInactiveAccounts extends RateLimitServiceP } try { const fresh = await fetchManagedAccountUsage(account, { - allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), networkProxySettings: this.networkProxySettingsResolver?.(), signal }) diff --git a/src/main/rate-limits/service/service-provider-cycles.ts b/src/main/rate-limits/service/service-provider-cycles.ts index fdb7040b58af..d60aab904ca0 100644 --- a/src/main/rate-limits/service/service-provider-cycles.ts +++ b/src/main/rate-limits/service/service-provider-cycles.ts @@ -106,8 +106,7 @@ export abstract class RateLimitServiceProviderCycles extends RateLimitServiceFul const claude = await fetchClaudeRateLimits({ authPreparation: claudeAuthPreparation, - allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), - allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), + allowCliLoginRefresh: this.shouldAllowClaudeCliLoginRefresh(claudeAuthPreparation), networkProxySettings: this.networkProxySettingsResolver?.(), signal }).catch((err): ProviderRateLimits => ({ diff --git a/src/main/runtime/claude-structured-fake-connection-test-fixture.ts b/src/main/runtime/claude-structured-fake-connection-test-fixture.ts index 3404bcbf283c..a7f19a6832bf 100644 --- a/src/main/runtime/claude-structured-fake-connection-test-fixture.ts +++ b/src/main/runtime/claude-structured-fake-connection-test-fixture.ts @@ -55,6 +55,10 @@ export function fakeClaude(providerSession: string) { connection.calls.push({ subtype: 'get_settings' }) return { env: {} } }, + getUsage: async () => { + connection.calls.push({ subtype: 'get_usage' }) + return {} + }, getContextUsage: async () => { connection.calls.push({ subtype: 'get_context_usage' }) return contextUsage() diff --git a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts index 07de5e58761a..c87aa0446e50 100644 --- a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts +++ b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts @@ -142,6 +142,7 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { announce() return Promise.resolve(initialized) }, + getUsage: async () => ({}), getContextUsage: () => { child.calls.push('get_context_usage') return Promise.resolve({}) diff --git a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt index 45f99ffa2467..2e510c34ff6d 100644 --- a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt +++ b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt @@ -26,7 +26,6 @@ main/git/wsl-git-read-environment.ts main/ipc/filesystem-watcher-wsl.ts main/local-worktree-filesystem.ts main/providers/local-pty-spawn.ts -main/rate-limits/claude-pty.ts main/rate-limits/codex-fetcher.ts main/wsl-availability.ts main/wsl-unc-delete.ts From 5b17f94a8953b5e937fab9dc96cdcf5fa8578915 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:25:11 -0700 Subject: [PATCH 2/4] fix(rate-limits): re-check the Claude account before a login refresh, and say when a login needs the user - Waiting out an account switch no longer starts Claude on whatever login the switch left in the shared Claude home (possibly the user's own): the refresh re-reads the selected account, bails on a change or an abort, and never starts Claude. - The 15-minute backoff is only set when Claude actually ran; a refresh Orca never started backs nothing off. - After Claude ran and the stored login is still expired, the meter says "Sign-in expired" and how to renew, instead of "Refreshing sign-in" forever. - A managed account with no login says "Signed out" and points to re-authentication, instead of "Refresh failed". --- .../claude-accounts/runtime-auth-service.ts | 4 + .../rate-limits/claude-active-usage-fetch.ts | 27 +++-- .../claude-cli-login-refresh.test.ts | 106 +++++++++++++++++- .../rate-limits/claude-cli-login-refresh.ts | 30 ++++- .../claude-fetcher-cli-login-refresh.test.ts | 80 ++++++++++--- .../claude-fetcher-fable-usage.test.ts | 10 +- src/main/rate-limits/claude-oauth-recovery.ts | 78 ++++++++----- .../claude-usage-error-classification.test.ts | 4 +- .../rate-limits/claude-usage-fetch-options.ts | 9 +- .../service-account-target-selection.test.ts | 19 +++- .../service-refresh-orchestration.test.ts | 2 +- .../service/service-configuration.ts | 5 + .../service/service-fetch-targets.ts | 16 ++- .../service/service-full-cycle-preparation.ts | 5 +- .../service/service-provider-cycles.ts | 2 +- src/main/rate-limits/service/service-state.ts | 2 + src/main/rate-limits/service/service-types.ts | 2 + .../startup/main-process-account-services.ts | 3 + .../src/components/status-bar/tooltip.test.ts | 27 +++++ .../components/status-bar/usage-error-copy.ts | 25 ++++- src/renderer/src/i18n/locales/en.json | 9 ++ src/renderer/src/i18n/locales/es.json | 14 +++ src/renderer/src/i18n/locales/fr.json | 9 ++ src/renderer/src/i18n/locales/ja.json | 9 ++ src/renderer/src/i18n/locales/ko.json | 9 ++ src/renderer/src/i18n/locales/zh.json | 9 ++ src/shared/rate-limit-types.ts | 1 + 27 files changed, 433 insertions(+), 83 deletions(-) diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index dead38a7780f..49d0db2b591e 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -60,6 +60,10 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { return this.getPreparation(target).configDir } + getCurrentAuthProvenance(target?: ClaudeAccountSelectionTarget): string { + return this.getPreparation(target).provenance + } + private initializeLastSyncedState(): void { const settings = this.store.getSettings() this.lastSyncedAccountId = getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) diff --git a/src/main/rate-limits/claude-active-usage-fetch.ts b/src/main/rate-limits/claude-active-usage-fetch.ts index b132f2011346..81a1258d0061 100644 --- a/src/main/rate-limits/claude-active-usage-fetch.ts +++ b/src/main/rate-limits/claude-active-usage-fetch.ts @@ -5,6 +5,7 @@ import { } from './claude-oauth-credentials' import { canRetryClaudeOAuthWithLegacyKeychain, + failureKindAfterClaudeLoginRepair, makeClaudeUsageClassificationError, makeLiveClaudeUsageDeferredResult, repairClaudeCredentialsThenRetryOAuth, @@ -34,7 +35,6 @@ export async function fetchActiveClaudeRateLimits( return abortedClaudeRateLimitResult() } const attempts = { attemptedSources: [] } - const allowCliLoginRefresh = options?.allowCliLoginRefresh === true if (options?.authPreparation?.runtime === 'wsl' && !options.authPreparation.wslLinuxConfigDir) { return makeClaudeUsageResult( @@ -97,20 +97,22 @@ export async function fetchActiveClaudeRateLimits( }) } - if (classification.shouldAttemptDelegatedRefresh && allowCliLoginRefresh) { - const repaired = await repairClaudeCredentialsThenRetryOAuth({ + let failureKind = classification.failureKind + if (classification.shouldAttemptDelegatedRefresh) { + const repair = await repairClaudeCredentialsThenRetryOAuth({ options, attempts, oauthCredentials }) - if (repaired) { - return repaired + if (repair.kind === 'result') { + return repair.result } + failureKind = failureKindAfterClaudeLoginRepair(repair, failureKind) } return makeClaudeUsageClassificationError({ error, - classification, + classification: { ...classification, failureKind }, attempts, oauthCredentials, authPreparation: options?.authPreparation @@ -132,19 +134,20 @@ export async function fetchActiveClaudeRateLimits( }) } + let absenceFailureKind = credentialClassification.failureKind if ( oauthCredentials.hasRefreshableCredentials && - credentialClassification.shouldAttemptDelegatedRefresh && - allowCliLoginRefresh + credentialClassification.shouldAttemptDelegatedRefresh ) { - const repaired = await repairClaudeCredentialsThenRetryOAuth({ + const repair = await repairClaudeCredentialsThenRetryOAuth({ options, attempts, oauthCredentials }) - if (repaired) { - return repaired + if (repair.kind === 'result') { + return repair.result } + absenceFailureKind = failureKindAfterClaudeLoginRepair(repair, absenceFailureKind) } if (oauthCredentials.keychainUnavailable) { @@ -164,7 +167,7 @@ export async function fetchActiveClaudeRateLimits( attemptedSources: attempts.attemptedSources, oauthCredentials, authPreparation: options?.authPreparation, - failureKind: credentialClassification.failureKind + failureKind: absenceFailureKind }) }) } diff --git a/src/main/rate-limits/claude-cli-login-refresh.test.ts b/src/main/rate-limits/claude-cli-login-refresh.test.ts index 26aeb8768ad4..a527e5f34af5 100644 --- a/src/main/rate-limits/claude-cli-login-refresh.test.ts +++ b/src/main/rate-limits/claude-cli-login-refresh.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from '../claude-accounts/live-pty-gate' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import type { ClaudeStreamJsonConnection, @@ -20,6 +21,7 @@ const authPreparation: ClaudeRuntimeAuthPreparation = { stripAuthEnv: true, provenance: 'managed:account-1' } +const stillSelected = (): string => authPreparation.provenance function fakeConnect(getUsage: (handlers: ClaudeStreamJsonConnectionHandlers) => Promise) { const seen: { launch?: ClaudeStreamJsonLaunch; closes: number; usageTimeout?: number } = { @@ -45,6 +47,10 @@ function fakeConnect(getUsage: (handlers: ClaudeStreamJsonConnectionHandlers) => } describe('refreshClaudeLoginViaCli', () => { + afterEach(() => { + endClaudeAuthSwitch() + }) + it('asks the account’s own Claude for usage in an isolated session-less child, then closes it', async () => { vi.stubEnv('ANTHROPIC_API_KEY', 'ambient-key') const { connect, seen } = fakeConnect(async () => ({ rate_limits_available: true })) @@ -52,6 +58,7 @@ describe('refreshClaudeLoginViaCli', () => { await expect( refreshClaudeLoginViaCli({ authPreparation, + readCurrentAuthProvenance: stillSelected, networkProxySettings: { httpProxyUrl: 'http://proxy.test:8080' }, connect, resolveCommand: () => '/fake/bin/claude' @@ -86,7 +93,12 @@ describe('refreshClaudeLoginViaCli', () => { }) await expect( - refreshClaudeLoginViaCli({ authPreparation, connect, resolveCommand: () => '/fake/claude' }) + refreshClaudeLoginViaCli({ + authPreparation, + readCurrentAuthProvenance: stillSelected, + connect, + resolveCommand: () => '/fake/claude' + }) ).resolves.toMatchObject({ kind: 'unsupported' }) expect(seen.closes).toBe(1) }) @@ -98,7 +110,12 @@ describe('refreshClaudeLoginViaCli', () => { }) await expect( - refreshClaudeLoginViaCli({ authPreparation, connect, resolveCommand: () => '/fake/claude' }) + refreshClaudeLoginViaCli({ + authPreparation, + readCurrentAuthProvenance: stillSelected, + connect, + resolveCommand: () => '/fake/claude' + }) ).resolves.toEqual({ kind: 'failed', message: 'claude stream-json exited (code 1): accept the updated terms' @@ -116,6 +133,7 @@ describe('refreshClaudeLoginViaCli', () => { await expect( refreshClaudeLoginViaCli({ authPreparation, + readCurrentAuthProvenance: stillSelected, connect, signal: controller.signal, resolveCommand: () => '/fake/claude' @@ -130,8 +148,86 @@ describe('refreshClaudeLoginViaCli', () => { const { connect } = fakeConnect(async () => ({})) await expect( - refreshClaudeLoginViaCli({ authPreparation, connect, signal: controller.signal }) - ).resolves.toMatchObject({ kind: 'failed' }) + refreshClaudeLoginViaCli({ + authPreparation, + readCurrentAuthProvenance: stillSelected, + connect, + signal: controller.signal + }) + ).resolves.toMatchObject({ kind: 'not-started' }) + expect(connect).not.toHaveBeenCalled() + }) + + it('starts nothing when aborted while an account switch settles', async () => { + const controller = new AbortController() + const { connect } = fakeConnect(async () => ({})) + beginClaudeAuthSwitch() + + const outcome = refreshClaudeLoginViaCli({ + authPreparation, + readCurrentAuthProvenance: stillSelected, + connect, + signal: controller.signal, + resolveCommand: () => '/fake/claude' + }) + controller.abort() + endClaudeAuthSwitch() + + await expect(outcome).resolves.toEqual({ kind: 'not-started', message: 'aborted' }) expect(connect).not.toHaveBeenCalled() }) + + it('starts nothing when the switch it waited for selected another login', async () => { + const { connect } = fakeConnect(async () => ({})) + let selected = authPreparation.provenance + beginClaudeAuthSwitch() + + const outcome = refreshClaudeLoginViaCli({ + authPreparation, + readCurrentAuthProvenance: () => selected, + connect, + resolveCommand: () => '/fake/claude' + }) + // The user switched back to their own login; the runtime home now holds it. + selected = 'system' + endClaudeAuthSwitch() + + await expect(outcome).resolves.toEqual({ + kind: 'not-started', + message: 'the selected Claude account changed' + }) + expect(connect).not.toHaveBeenCalled() + }) + + it('starts nothing when the account changed before the refresh began', async () => { + const { connect } = fakeConnect(async () => ({})) + + await expect( + refreshClaudeLoginViaCli({ + authPreparation, + readCurrentAuthProvenance: () => 'managed:account-2', + connect, + resolveCommand: () => '/fake/claude' + }) + ).resolves.toMatchObject({ kind: 'not-started' }) + expect(connect).not.toHaveBeenCalled() + }) + + it('reports a connection that never started Claude as not started', async () => { + const connect = vi.fn(async () => { + throw new Error('query() returned without spawning a child') + }) + + await expect( + refreshClaudeLoginViaCli({ + authPreparation, + readCurrentAuthProvenance: stillSelected, + connect, + resolveCommand: () => '/fake/claude' + }) + ).resolves.toEqual({ + kind: 'not-started', + message: 'query() returned without spawning a child' + }) + }) }) diff --git a/src/main/rate-limits/claude-cli-login-refresh.ts b/src/main/rate-limits/claude-cli-login-refresh.ts index 0f3252afb3b6..f01f4f5694dc 100644 --- a/src/main/rate-limits/claude-cli-login-refresh.ts +++ b/src/main/rate-limits/claude-cli-login-refresh.ts @@ -1,7 +1,10 @@ import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy' import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import { applyClaudeEnvPatch } from '../claude-accounts/environment' -import { whenClaudeAuthSwitchSettles } from '../claude-accounts/live-pty-gate' +import { + isClaudeAuthSwitchInProgress, + whenClaudeAuthSwitchSettles +} from '../claude-accounts/live-pty-gate' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import { withoutInheritedClaudeConfigDir } from '../claude/claude-config-dir-pin' import { @@ -18,7 +21,10 @@ export type ClaudeCliLoginRefreshOutcome = | { kind: 'answered' } /** The installed CLI has no `get_usage`; retrying the same binary cannot change that. */ | { kind: 'unsupported'; message: string } + /** Claude started but never answered (it exited, timed out, or was stopped). */ | { kind: 'failed'; message: string } + /** Orca never started Claude, so the stored login cannot have changed because of it. */ + | { kind: 'not-started'; message: string } type ConnectClaude = typeof openClaudeStreamJsonConnection @@ -34,17 +40,29 @@ function isUnsupportedSubtype(message: string): boolean { */ export async function refreshClaudeLoginViaCli(input: { authPreparation: ClaudeRuntimeAuthPreparation + readCurrentAuthProvenance: () => string networkProxySettings?: NetworkProxySettings signal?: AbortSignal connect?: ConnectClaude resolveCommand?: () => string }): Promise { if (input.signal?.aborted) { - return { kind: 'failed', message: 'aborted' } + return { kind: 'not-started', message: 'aborted' } } // A switch re-materializes the runtime credentials this child would refresh. if (!(await whenClaudeAuthSwitchSettles())) { - return { kind: 'failed', message: 'a Claude account switch is in progress' } + return { kind: 'not-started', message: 'a Claude account switch is in progress' } + } + if (input.signal?.aborted) { + return { kind: 'not-started', message: 'aborted' } + } + // Every login shares the runtime home, so after a switch the child would refresh whichever + // account is now selected, possibly the user's own system login. + if ( + isClaudeAuthSwitchInProgress() || + input.readCurrentAuthProvenance() !== input.authPreparation.provenance + ) { + return { kind: 'not-started', message: 'the selected Claude account changed' } } const command = (input.resolveCommand ?? resolveClaudeCommand)() const launch: ClaudeStreamJsonLaunch = { @@ -84,6 +102,12 @@ export async function refreshClaudeLoginViaCli(input: { fault.first ??= error } }) + } catch (error) { + input.signal?.removeEventListener('abort', closeOnAbort) + // The connection throws only before a child exists. + return { kind: 'not-started', message: error instanceof Error ? error.message : String(error) } + } + try { if (input.signal?.aborted) { return { kind: 'failed', message: 'aborted' } } diff --git a/src/main/rate-limits/claude-fetcher-cli-login-refresh.test.ts b/src/main/rate-limits/claude-fetcher-cli-login-refresh.test.ts index df593edbd6ad..9932e81e13ec 100644 --- a/src/main/rate-limits/claude-fetcher-cli-login-refresh.test.ts +++ b/src/main/rate-limits/claude-fetcher-cli-login-refresh.test.ts @@ -8,6 +8,7 @@ import { } from '../claude-accounts/keychain' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import type * as CodexCliCommand from '../codex-cli/command' +import type { ClaudeCliLoginRefreshPermit } from './claude-usage-fetch-options' const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( () => ({ @@ -74,6 +75,11 @@ function managedAuth( } } +// The login this fetch was prepared for is still the selected one. +function refreshPermit(): ClaudeCliLoginRefreshPermit { + return { readCurrentAuthProvenance: () => 'managed:account-1' } +} + function storedLogin(accessToken: string | undefined, expiresInMs = -60_000): string { return JSON.stringify({ claudeAiOauth: { @@ -124,7 +130,7 @@ describe('fetchClaudeRateLimits without a hidden interactive Claude', () => { const before = Date.now() const result = await fetchClaudeRateLimits({ authPreparation: managedAuth(), - allowCliLoginRefresh: true + cliLoginRefresh: refreshPermit() }) expect(result).toMatchObject({ status: 'error', @@ -155,7 +161,7 @@ describe('fetchClaudeRateLimits without a hidden interactive Claude', () => { netFetchMock.mockResolvedValueOnce(usage(14, 27)) await expect( - fetchClaudeRateLimits({ authPreparation, allowCliLoginRefresh: true }) + fetchClaudeRateLimits({ authPreparation, cliLoginRefresh: refreshPermit() }) ).resolves.toMatchObject({ status: 'ok', session: { usedPercent: 14 }, @@ -174,12 +180,36 @@ describe('fetchClaudeRateLimits without a hidden interactive Claude', () => { ) }) - it('reports the expired login, and backs off, when the CLI saved nothing new', async () => { + it('says the user must renew the login, and backs off, when Claude saved nothing new', async () => { + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue( + storedLogin('stale-oauth-token') + ) + netFetchMock.mockResolvedValue(usageError(401, 'authentication_error')) + const options = { authPreparation: managedAuth(), cliLoginRefresh: refreshPermit() } + + await expect(fetchClaudeRateLimits(options)).resolves.toMatchObject({ + status: 'error', + usageMetadata: { failureKind: 'delegated-refresh-required' } + }) + // Still true during the backoff: nothing is renewing the login. + await expect(fetchClaudeRateLimits(options)).resolves.toMatchObject({ + usageMetadata: { failureKind: 'delegated-refresh-required' } + }) + + // The usage answer is never trusted: only the stored login decides, so one probe per backoff. + expect(refreshClaudeLoginViaCli).toHaveBeenCalledTimes(1) + }) + + it('does not back off when Claude was never started', async () => { + vi.mocked(refreshClaudeLoginViaCli).mockResolvedValue({ + kind: 'not-started', + message: 'the selected Claude account changed' + }) vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue( storedLogin('stale-oauth-token') ) netFetchMock.mockResolvedValue(usageError(401, 'authentication_error')) - const options = { authPreparation: managedAuth(), allowCliLoginRefresh: true } + const options = { authPreparation: managedAuth(), cliLoginRefresh: refreshPermit() } await expect(fetchClaudeRateLimits(options)).resolves.toMatchObject({ status: 'error', @@ -187,7 +217,25 @@ describe('fetchClaudeRateLimits without a hidden interactive Claude', () => { }) await fetchClaudeRateLimits(options) - // The usage answer is never trusted: only the stored login decides, so one probe per backoff. + expect(refreshClaudeLoginViaCli).toHaveBeenCalledTimes(2) + }) + + it('backs off when Claude started but exited before answering', async () => { + vi.mocked(refreshClaudeLoginViaCli).mockResolvedValue({ + kind: 'failed', + message: 'claude stream-json exited (code 1)' + }) + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue( + storedLogin('stale-oauth-token') + ) + netFetchMock.mockResolvedValue(usageError(401, 'authentication_error')) + const options = { authPreparation: managedAuth(), cliLoginRefresh: refreshPermit() } + + await expect(fetchClaudeRateLimits(options)).resolves.toMatchObject({ + usageMetadata: { failureKind: 'delegated-refresh-required' } + }) + await fetchClaudeRateLimits(options) + expect(refreshClaudeLoginViaCli).toHaveBeenCalledTimes(1) }) @@ -196,15 +244,17 @@ describe('fetchClaudeRateLimits without a hidden interactive Claude', () => { kind: 'unsupported', message: 'Unsupported control request subtype: get_usage' }) + // A changed login does not lift the per-binary latch. vi.mocked(readActiveClaudeKeychainCredentialsStrict) - .mockResolvedValueOnce(storedLogin('stale-oauth-token')) .mockResolvedValueOnce(storedLogin('stale-oauth-token')) .mockResolvedValueOnce(storedLogin('other-stale-token')) netFetchMock.mockResolvedValue(usageError(401, 'authentication_error')) - const options = { authPreparation: managedAuth(), allowCliLoginRefresh: true } + const options = { authPreparation: managedAuth(), cliLoginRefresh: refreshPermit() } await fetchClaudeRateLimits(options) - await fetchClaudeRateLimits(options) + await expect(fetchClaudeRateLimits(options)).resolves.toMatchObject({ + usageMetadata: { failureKind: 'delegated-refresh-required' } + }) expect(refreshClaudeLoginViaCli).toHaveBeenCalledTimes(1) }) @@ -229,7 +279,7 @@ describe('fetchClaudeRateLimits without a hidden interactive Claude', () => { netFetchMock.mockResolvedValueOnce(new Response('temporary failure', { status: 500 })) await expect( - fetchClaudeRateLimits({ authPreparation: managedAuth(), allowCliLoginRefresh: true }) + fetchClaudeRateLimits({ authPreparation: managedAuth(), cliLoginRefresh: refreshPermit() }) ).resolves.toMatchObject({ status: 'error', error: 'OAuth API returned 500' }) expect(refreshClaudeLoginViaCli).not.toHaveBeenCalled() }) @@ -243,7 +293,7 @@ describe('fetchClaudeRateLimits without a hidden interactive Claude', () => { await expect( fetchClaudeRateLimits({ authPreparation: managedAuth({ managedRefreshDeferredByLivePty: true }), - allowCliLoginRefresh: true + cliLoginRefresh: refreshPermit() }) ).resolves.toMatchObject({ status: 'error', @@ -257,7 +307,7 @@ describe('fetchClaudeRateLimits without a hidden interactive Claude', () => { await expect( fetchClaudeRateLimits({ authPreparation: managedAuth({ managedRefreshDeferredByLivePty: true }), - allowCliLoginRefresh: true + cliLoginRefresh: refreshPermit() }) ).resolves.toMatchObject({ status: 'error', @@ -277,7 +327,7 @@ describe('fetchClaudeRateLimits without a hidden interactive Claude', () => { .mockResolvedValueOnce(storedLogin('refreshed-oauth-token', 60_000)) await expect( - fetchClaudeRateLimits({ authPreparation: managedAuth(), allowCliLoginRefresh: true }) + fetchClaudeRateLimits({ authPreparation: managedAuth(), cliLoginRefresh: refreshPermit() }) ).resolves.toMatchObject({ status: 'ok', session: { usedPercent: 12 } }) expect(refreshClaudeLoginViaCli).toHaveBeenCalledTimes(1) }) @@ -296,7 +346,7 @@ describe('fetchClaudeRateLimits without a hidden interactive Claude', () => { it('shows a signed-out managed account as an error, never by starting Claude', async () => { await expect( - fetchClaudeRateLimits({ authPreparation: managedAuth(), allowCliLoginRefresh: true }) + fetchClaudeRateLimits({ authPreparation: managedAuth(), cliLoginRefresh: refreshPermit() }) ).resolves.toMatchObject({ status: 'error', error: 'Claude account is signed out', @@ -318,7 +368,9 @@ describe('fetchClaudeRateLimits without a hidden interactive Claude', () => { new Error('security timed out after 3000ms') ) - await expect(fetchClaudeRateLimits({ allowCliLoginRefresh: true })).resolves.toMatchObject({ + await expect( + fetchClaudeRateLimits({ cliLoginRefresh: refreshPermit() }) + ).resolves.toMatchObject({ status: 'error', error: 'Claude Keychain credentials unavailable', usageMetadata: { failureKind: 'keychain-unavailable', attemptedSources: [] } diff --git a/src/main/rate-limits/claude-fetcher-fable-usage.test.ts b/src/main/rate-limits/claude-fetcher-fable-usage.test.ts index 075d60e8d0de..f07f5dbbf934 100644 --- a/src/main/rate-limits/claude-fetcher-fable-usage.test.ts +++ b/src/main/rate-limits/claude-fetcher-fable-usage.test.ts @@ -131,7 +131,10 @@ describe('fetchClaudeRateLimits', () => { ) await expect( - fetchClaudeRateLimits({ authPreparation, allowCliLoginRefresh: true }) + fetchClaudeRateLimits({ + authPreparation, + cliLoginRefresh: { readCurrentAuthProvenance: () => authPreparation.provenance } + }) ).resolves.toMatchObject({ provider: 'claude', status: 'ok', @@ -155,7 +158,10 @@ describe('fetchClaudeRateLimits', () => { JSON.stringify({ claudeAiOauth: { accessToken: 'oauth-token' } }) ) - const result = await fetchClaudeRateLimits({ authPreparation, allowCliLoginRefresh: true }) + const result = await fetchClaudeRateLimits({ + authPreparation, + cliLoginRefresh: { readCurrentAuthProvenance: () => authPreparation.provenance } + }) expect(result).toMatchObject({ status: 'ok', session: { usedPercent: 12 } }) expect(result.fableWeekly ?? null).toBeNull() diff --git a/src/main/rate-limits/claude-oauth-recovery.ts b/src/main/rate-limits/claude-oauth-recovery.ts index d72176f0eeb1..9e015af15601 100644 --- a/src/main/rate-limits/claude-oauth-recovery.ts +++ b/src/main/rate-limits/claude-oauth-recovery.ts @@ -1,5 +1,5 @@ import { realpathSync, statSync } from 'node:fs' -import type { ProviderRateLimits } from '../../shared/rate-limit-types' +import type { ProviderRateLimits, UsageRateLimitFailureKind } from '../../shared/rate-limit-types' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import { resolveClaudeCommand } from '../codex-cli/command' import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' @@ -145,42 +145,48 @@ export function resetClaudeLoginRefreshLatchesForTests(): void { unsupportedCliBinaries.clear() } +export type ClaudeLoginRepair = + | { kind: 'result'; result: ProviderRateLimits } + /** Claude had its chance at this login, now or within the backoff, and it is still expired. */ + | { kind: 'not-renewed' } + /** Nothing was learned about the login; report the original failure. */ + | { kind: 'unresolved' } + /** * Lets the account's own Claude CLI refresh its expired login, then retries the usage endpoint - * with whatever it saved. Null means usage is still unknown and the caller reports why. + * with whatever it saved. */ export async function repairClaudeCredentialsThenRetryOAuth(input: { options?: ClaudeRateLimitFetchOptions attempts: ClaudeUsageAttemptState oauthCredentials: ClaudeOAuthCredentialReadResult -}): Promise { +}): Promise { const authPreparation = input.options?.authPreparation + const permit = input.options?.cliLoginRefresh if (input.options?.signal?.aborted) { - return abortedClaudeRateLimitResult() + return { kind: 'result', result: abortedClaudeRateLimitResult() } } - if (!authPreparation) { - return null + if (!authPreparation || !permit) { + return { kind: 'unresolved' } } const before = credentialStateKey(input.oauthCredentials) const failed = failedLoginRefreshByProvenance.get(authPreparation.provenance) if (failed?.state === before && Date.now() < failed.retryAtMs) { - return null + return { kind: 'not-renewed' } } const command = resolveClaudeCommand() const binary = claudeBinaryKey(command) if (unsupportedCliBinaries.has(binary)) { - return null + return { kind: 'not-renewed' } } recordClaudeUsageAttempt(input.attempts, 'cli') const outcome = await refreshClaudeLoginViaCli({ authPreparation, + readCurrentAuthProvenance: permit.readCurrentAuthProvenance, networkProxySettings: input.options?.networkProxySettings, signal: input.options?.signal, resolveCommand: () => command }) - if (outcome.kind === 'unsupported') { - unsupportedCliBinaries.add(binary) - } if (outcome.kind !== 'answered') { warnClaudeUsageFetchFailure( authPreparation, @@ -188,42 +194,64 @@ export async function repairClaudeCredentialsThenRetryOAuth(input: { new Error(`Claude CLI login refresh ${outcome.kind}: ${outcome.message}`) ) } - if (input.options?.signal?.aborted) { - return abortedClaudeRateLimitResult() + return { kind: 'result', result: abortedClaudeRateLimitResult() } + } + if (outcome.kind === 'not-started') { + // Claude never ran, so there is nothing to re-read and no reason to back off. + return { kind: 'unresolved' } + } + if (outcome.kind === 'unsupported') { + unsupportedCliBinaries.add(binary) + return { kind: 'not-renewed' } } + const refreshed = await readClaudeOAuthCredentials( resolveClaudeOAuthCredentialReadOptions(authPreparation) ) if (input.options?.signal?.aborted) { - return abortedClaudeRateLimitResult() + return { kind: 'result', result: abortedClaudeRateLimitResult() } } if (credentialStateKey(refreshed) === before) { - // The CLI saved nothing new; asking again before the login changes would fail the same way. + // Claude ran and saved nothing new; asking again before the login changes would fail the same way. failedLoginRefreshByProvenance.set(authPreparation.provenance, { state: before, retryAtMs: Date.now() + FAILED_LOGIN_REFRESH_BACKOFF_MS }) - return null + return { kind: 'not-renewed' } } failedLoginRefreshByProvenance.delete(authPreparation.provenance) if (!refreshed.token) { - return null + return { kind: 'unresolved' } } recordClaudeUsageAttempt(input.attempts, 'oauth') try { const limits = await fetchClaudeOAuthUsage(refreshed.token, input.options?.signal) if (input.options?.signal?.aborted) { - return abortedClaudeRateLimitResult() + return { kind: 'result', result: abortedClaudeRateLimitResult() } + } + return { + kind: 'result', + result: claudeOAuthUsageSuccess({ + limits, + oauthCredentials: refreshed, + attempts: input.attempts, + authPreparation + }) } - return claudeOAuthUsageSuccess({ - limits, - oauthCredentials: refreshed, - attempts: input.attempts, - authPreparation - }) } catch (error) { warnClaudeUsageFetchFailure(authPreparation, refreshed, error) - return null + return { kind: 'unresolved' } } } + +/** + * After Claude failed to renew the login, only the user can: by running Claude on the account + * (it renews or asks them to sign in) or re-authenticating it. + */ +export function failureKindAfterClaudeLoginRepair( + repair: ClaudeLoginRepair, + failureKind: UsageRateLimitFailureKind +): UsageRateLimitFailureKind { + return repair.kind === 'not-renewed' ? 'delegated-refresh-required' : failureKind +} diff --git a/src/main/rate-limits/claude-usage-error-classification.test.ts b/src/main/rate-limits/claude-usage-error-classification.test.ts index b77c6271d1a0..4f52b6e1b356 100644 --- a/src/main/rate-limits/claude-usage-error-classification.test.ts +++ b/src/main/rate-limits/claude-usage-error-classification.test.ts @@ -6,7 +6,7 @@ import { import { OAuthUsageError } from './claude-oauth-usage-error' describe('classifyClaudeOAuthUsageError', () => { - it('treats OAuth unauthorized as stale-token repair/fallback', () => { + it('treats OAuth unauthorized as a stale token Claude may renew', () => { expect( classifyClaudeOAuthUsageError(new OAuthUsageError('Invalid OAuth token', 401, true)) ).toMatchObject({ @@ -37,7 +37,7 @@ describe('classifyClaudeOAuthUsageError', () => { }) }) - it('allows CLI fallback for network-shaped failures', () => { + it('reports network-shaped failures without asking Claude to renew the login', () => { expect(classifyClaudeOAuthUsageError(new Error('fetch failed: ENOTFOUND'))).toMatchObject({ failureKind: 'network', shouldAttemptDelegatedRefresh: false diff --git a/src/main/rate-limits/claude-usage-fetch-options.ts b/src/main/rate-limits/claude-usage-fetch-options.ts index 7f84ea79f183..1f5a1e1adf1e 100644 --- a/src/main/rate-limits/claude-usage-fetch-options.ts +++ b/src/main/rate-limits/claude-usage-fetch-options.ts @@ -1,10 +1,15 @@ import type { NetworkProxySettings } from '../../shared/network-proxy' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' +/** Lets the account's own Claude CLI refresh an expired login; absent means never start Claude. */ +export type ClaudeCliLoginRefreshPermit = { + /** The login now selected for this fetch's target, read without syncing. */ + readCurrentAuthProvenance: () => string +} + export type ClaudeRateLimitFetchOptions = { authPreparation?: ClaudeRuntimeAuthPreparation - /** Let the account's own Claude CLI refresh an expired login; off unless the caller opts in. */ - allowCliLoginRefresh?: boolean + cliLoginRefresh?: ClaudeCliLoginRefreshPermit networkProxySettings?: NetworkProxySettings signal?: AbortSignal } diff --git a/src/main/rate-limits/service-account-target-selection.test.ts b/src/main/rate-limits/service-account-target-selection.test.ts index af254325b3ac..dc9ea57c32c5 100644 --- a/src/main/rate-limits/service-account-target-selection.test.ts +++ b/src/main/rate-limits/service-account-target-selection.test.ts @@ -371,7 +371,9 @@ describe('RateLimitService', () => { stripAuthEnv: target?.runtime === 'wsl', provenance: target?.runtime === 'wsl' ? 'managed:wsl-account:wsl:Ubuntu' : 'system' })) + const provenanceReader = vi.fn(() => 'system') service.setClaudeAuthPreparationResolver(resolver) + service.setClaudeAuthProvenanceReader(provenanceReader) service.setClaudeFetchTarget({ runtime: 'wsl', wslDistro: 'Ubuntu' }) vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) @@ -388,10 +390,14 @@ describe('RateLimitService', () => { wslLinuxConfigDir: '/home/jin/.claude', stripAuthEnv: true }), - allowCliLoginRefresh: true, + cliLoginRefresh: { readCurrentAuthProvenance: expect.any(Function) }, signal: expect.any(AbortSignal) }) ) + // The refresh re-reads the login selected for the fetch's own target, not the default one. + const permit = vi.mocked(fetchClaudeRateLimits).mock.calls[0]?.[0]?.cliLoginRefresh + expect(permit?.readCurrentAuthProvenance()).toBe('system') + expect(provenanceReader).toHaveBeenCalledWith({ runtime: 'wsl', wslDistro: 'Ubuntu' }) expect(service.getState().claudeTarget).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) }) @@ -415,7 +421,7 @@ describe('RateLimitService', () => { expect(fetchClaudeRateLimits).toHaveBeenCalledWith( expect.objectContaining({ authPreparation: expect.objectContaining({ provenance: 'system' }), - allowCliLoginRefresh: false, + cliLoginRefresh: undefined, signal: expect.any(AbortSignal) }) ) @@ -432,7 +438,7 @@ describe('RateLimitService', () => { expect(fetchClaudeRateLimits).toHaveBeenCalledWith( expect.objectContaining({ authPreparation: undefined, - allowCliLoginRefresh: false, + cliLoginRefresh: undefined, signal: expect.any(AbortSignal) }) ) @@ -459,7 +465,7 @@ describe('RateLimitService', () => { expect(fetchClaudeRateLimits).toHaveBeenCalledWith( expect.objectContaining({ authPreparation: expect.objectContaining({ provenance: 'wsl:Ubuntu:system' }), - allowCliLoginRefresh: false, + cliLoginRefresh: undefined, signal: expect.any(AbortSignal) }) ) @@ -562,6 +568,7 @@ describe('RateLimitService', () => { stripAuthEnv: target?.runtime === 'wsl', provenance: target?.runtime === 'wsl' ? 'managed:wsl-account-1:wsl:Ubuntu' : 'system' })) + service.setClaudeAuthProvenanceReader(() => 'managed:wsl-account-1:wsl:Ubuntu') vi.mocked(fetchClaudeRateLimits) .mockResolvedValueOnce(okProvider('claude', 20, Date.now())) @@ -575,7 +582,9 @@ describe('RateLimitService', () => { }) expect(fetchClaudeRateLimits).toHaveBeenLastCalledWith( - expect.objectContaining({ allowCliLoginRefresh: true }) + expect.objectContaining({ + cliLoginRefresh: { readCurrentAuthProvenance: expect.any(Function) } + }) ) expect(service.getState().inactiveClaudeAccounts).not.toEqual( diff --git a/src/main/rate-limits/service-refresh-orchestration.test.ts b/src/main/rate-limits/service-refresh-orchestration.test.ts index 4ff5da5c9cab..62a1e91b951b 100644 --- a/src/main/rate-limits/service-refresh-orchestration.test.ts +++ b/src/main/rate-limits/service-refresh-orchestration.test.ts @@ -452,7 +452,7 @@ describe('RateLimitService', () => { expect(fetchClaudeRateLimits).toHaveBeenCalledWith( expect.objectContaining({ authPreparation: undefined, - allowCliLoginRefresh: false, + cliLoginRefresh: undefined, signal: expect.any(AbortSignal) }) ) diff --git a/src/main/rate-limits/service/service-configuration.ts b/src/main/rate-limits/service/service-configuration.ts index 2a788b1cf935..9dc25ff098a6 100644 --- a/src/main/rate-limits/service/service-configuration.ts +++ b/src/main/rate-limits/service/service-configuration.ts @@ -8,6 +8,7 @@ import { type KimiHomeResolver, type ClaudeAccountSelectionTarget, type ClaudeAuthPreparationResolver, + type ClaudeAuthProvenanceReader, type OpenCodeGoRateLimitConfig, type MiniMaxRateLimitConfig, type GeminiCliOAuthEnabledResolver, @@ -36,6 +37,10 @@ export abstract class RateLimitServiceConfiguration extends RateLimitServiceAcco this.claudeAuthPreparationResolver = resolver } + setClaudeAuthProvenanceReader(reader: ClaudeAuthProvenanceReader): void { + this.claudeAuthProvenanceReader = reader + } + setClaudeFetchTarget(target?: ClaudeAccountSelectionTarget): void { this.claudeFetchTarget = normalizeClaudeAccountSelectionTarget(target) } diff --git a/src/main/rate-limits/service/service-fetch-targets.ts b/src/main/rate-limits/service/service-fetch-targets.ts index 5ace421f8707..ae2274daa415 100644 --- a/src/main/rate-limits/service/service-fetch-targets.ts +++ b/src/main/rate-limits/service/service-fetch-targets.ts @@ -13,6 +13,7 @@ import { toErrorMessage } from './service-types' import type { CodexRateLimitResetOutcome } from '../../../shared/rate-limit-types' +import type { ClaudeCliLoginRefreshPermit } from '../claude-usage-fetch-options' const CODEX_RESET_REFRESH_RETRIES = 3 const CODEX_RESET_REFRESH_DELAY_MS = 250 @@ -171,15 +172,20 @@ export abstract class RateLimitServiceFetchTargets extends RateLimitServiceResul return { ...stateBeforeReset, codex: scopedCodex, codexTarget: target } } - protected shouldAllowClaudeCliLoginRefresh( - authPreparation: ClaudeRuntimeAuthPreparation | undefined - ): boolean { + protected claudeCliLoginRefreshPermit( + authPreparation: ClaudeRuntimeAuthPreparation | undefined, + target: NormalizedClaudeAccountSelectionTarget + ): ClaudeCliLoginRefreshPermit | undefined { // Why: Windows managed accounts can live in WSL, which this host-side refresh does not reach. if (process.platform === 'win32') { - return false + return undefined } + const reader = this.claudeAuthProvenanceReader // Why: a system-default login is the user's own; Orca only reads it and never starts Claude for it. - return !isSystemDefaultClaudeAuth(authPreparation) + if (isSystemDefaultClaudeAuth(authPreparation) || !reader) { + return undefined + } + return { readCurrentAuthProvenance: () => reader(target) } } protected resolveMiniMaxConfig(): MiniMaxResolvedConfig { diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts index cf830744bb0e..c81e405df1a5 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -183,7 +183,10 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ ? Promise.resolve(previousState.claude as ProviderRateLimits) : fetchClaudeRateLimits({ authPreparation: claudeAuthPreparation, - allowCliLoginRefresh: this.shouldAllowClaudeCliLoginRefresh(claudeAuthPreparation), + cliLoginRefresh: this.claudeCliLoginRefreshPermit( + claudeAuthPreparation, + claudeTarget + ), networkProxySettings: this.networkProxySettingsResolver?.(), signal }), diff --git a/src/main/rate-limits/service/service-provider-cycles.ts b/src/main/rate-limits/service/service-provider-cycles.ts index d60aab904ca0..677bd53e8647 100644 --- a/src/main/rate-limits/service/service-provider-cycles.ts +++ b/src/main/rate-limits/service/service-provider-cycles.ts @@ -106,7 +106,7 @@ export abstract class RateLimitServiceProviderCycles extends RateLimitServiceFul const claude = await fetchClaudeRateLimits({ authPreparation: claudeAuthPreparation, - allowCliLoginRefresh: this.shouldAllowClaudeCliLoginRefresh(claudeAuthPreparation), + cliLoginRefresh: this.claudeCliLoginRefreshPermit(claudeAuthPreparation, claudeTarget), networkProxySettings: this.networkProxySettingsResolver?.(), signal }).catch((err): ProviderRateLimits => ({ diff --git a/src/main/rate-limits/service/service-state.ts b/src/main/rate-limits/service/service-state.ts index 7af82c6c3135..be7da330ae2b 100644 --- a/src/main/rate-limits/service/service-state.ts +++ b/src/main/rate-limits/service/service-state.ts @@ -11,6 +11,7 @@ import { type CodexHomePathResolver, type KimiHomeResolver, type ClaudeAuthPreparationResolver, + type ClaudeAuthProvenanceReader, type OpenCodeGoRateLimitConfig, type MiniMaxRateLimitConfig, type GeminiCliOAuthEnabledResolver, @@ -94,6 +95,7 @@ export abstract class RateLimitServiceState { // Why: resolved per cycle — the local-account runtime policy can flip between fetches. protected kimiHomeResolver: KimiHomeResolver | null = null protected claudeAuthPreparationResolver: ClaudeAuthPreparationResolver | null = null + protected claudeAuthProvenanceReader: ClaudeAuthProvenanceReader | null = null protected claudeFetchTarget: NormalizedClaudeAccountSelectionTarget = { runtime: 'host', wslDistro: null diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts index a3998c13abe4..215dbe5aea77 100644 --- a/src/main/rate-limits/service/service-types.ts +++ b/src/main/rate-limits/service/service-types.ts @@ -40,6 +40,8 @@ export type KimiHomeResolver = () => Promise export type ClaudeAuthPreparationResolver = ( target?: ClaudeAccountSelectionTarget ) => Promise +/** The provenance of the login now selected for a target, read without syncing. */ +export type ClaudeAuthProvenanceReader = (target?: ClaudeAccountSelectionTarget) => string export type OpenCodeGoRateLimitConfig = { sessionCookie: string diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts index 6abf73d66106..9843373f3bc5 100644 --- a/src/main/startup/main-process-account-services.ts +++ b/src/main/startup/main-process-account-services.ts @@ -106,6 +106,9 @@ export function initializeMainProcessAccountServices(): void { state.rateLimits.setClaudeAuthPreparationResolver((target) => state.claudeRuntimeAuth!.prepareForRateLimitFetch(target) ) + state.rateLimits.setClaudeAuthProvenanceReader((target) => + state.claudeRuntimeAuth!.getCurrentAuthProvenance(target) + ) // Why: live Claude sessions stream usage windows through their statusLine command; feeding them here avoids OAuth usage-endpoint polling (and its 429s). agentHookServer.setClaudeStatusLineListener((event) => { state.rateLimits!.ingestLiveClaudeRateLimits(event) diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index f08f1e4a1ebd..d3e5d670b71e 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -261,6 +261,33 @@ describe('provider usage error copy', () => { ) }) + it('tells the user to renew a Claude login that Claude itself could not renew', () => { + const p = provider({ + error: 'OAuth API returned 401', + usageMetadata: { + failureKind: 'delegated-refresh-required', + attemptedSources: ['oauth', 'cli'] + } + }) + + expect(getProviderUsageStatusLabel(p)).toBe('Sign-in expired') + expect(getProviderUsageErrorMessage(p)).toBe( + 'Claude could not renew this account’s sign-in. Start a Claude session on it to renew, or re-authenticate it in Settings → Accounts.' + ) + }) + + it('says a managed Claude account with no login is signed out, not that a refresh failed', () => { + const p = provider({ + error: 'Claude account is signed out', + usageMetadata: { failureKind: 'missing-credentials', attemptedSources: [] } + }) + + expect(getProviderUsageStatusLabel(p)).toBe('Signed out') + expect(getProviderUsageErrorMessage(p)).toBe( + 'This Claude account is signed out. Re-authenticate it in Settings → Accounts to see its usage.' + ) + }) + it('uses structured network copy for Claude usage failures', () => { const p = provider({ error: 'Network error while refreshing OAuth usage: ECONNRESET', diff --git a/src/renderer/src/components/status-bar/usage-error-copy.ts b/src/renderer/src/components/status-bar/usage-error-copy.ts index 95942ddb5b72..db91b75e5a9a 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.ts @@ -77,7 +77,7 @@ function getDelegatedCliRefreshProvider( return null } // Why: only these providers require a user-run CLI to rotate the read-only - // session Orca consumes; Claude handles the same failure kind in-app. + // session Orca consumes; Claude has its own copy for the same failure kind. return p.provider === 'grok' || p.provider === 'kimi' ? p.provider : null } @@ -98,8 +98,15 @@ export function getProviderUsageStatusLabel(p: ProviderRateLimits): string { ) case 'stale-token': case 'refreshable-credentials-without-token': - case 'delegated-refresh-required': return translate('auto.components.status.bar.tooltip.1804cd8c3f', 'Refreshing sign-in') + // Why: Claude already tried to renew this login and could not; only the user can now. + case 'delegated-refresh-required': + return translate( + 'auto.components.status.bar.tooltip.minimax.expired.label', + 'Sign-in expired' + ) + case 'missing-credentials': + return translate('auto.components.status.bar.tooltip.claude.signedOut.label', 'Signed out') case 'network': return translate('auto.components.status.bar.tooltip.f8f0f9d8cc', 'Network issue') case 'keychain-unavailable': @@ -107,7 +114,6 @@ export function getProviderUsageStatusLabel(p: ProviderRateLimits): string { case 'cli-unavailable': case 'usage-unavailable': return translate('auto.components.status.bar.tooltip.f8b8dbed85', 'Usage unavailable') - case 'missing-credentials': case 'missing-scope': case 'no-subscription': case 'parse': @@ -178,11 +184,21 @@ export function getProviderUsageErrorMessage(p: ProviderRateLimits): string { ) case 'stale-token': case 'refreshable-credentials-without-token': - case 'delegated-refresh-required': return translate( 'auto.components.status.bar.tooltip.42fdd4da1d', 'Claude sign-in is being refreshed. Agent sessions may still be signed in.' ) + // Why: the renewal may have failed on the network or on a revoked login; both end the same way. + case 'delegated-refresh-required': + return translate( + 'auto.components.status.bar.tooltip.claude.renewFailed.message', + 'Claude could not renew this account’s sign-in. Start a Claude session on it to renew, or re-authenticate it in Settings → Accounts.' + ) + case 'missing-credentials': + return translate( + 'auto.components.status.bar.tooltip.claude.signedOut.message', + 'This Claude account is signed out. Re-authenticate it in Settings → Accounts to see its usage.' + ) case 'missing-scope': return p.error case 'network': @@ -203,7 +219,6 @@ export function getProviderUsageErrorMessage(p: ProviderRateLimits): string { 'auto.components.status.bar.tooltip.a7517cccb6', 'Claude usage is unavailable right now.' ) - case 'missing-credentials': case 'no-subscription': case 'rate-limited': case 'unknown': diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d8ecc0df0040..7e1509923064 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4038,6 +4038,15 @@ "message": "Run cursor-agent login in a terminal on the computer running Orca, then retry usage." }, "plan": "Plan" + }, + "claude": { + "signedOut": { + "label": "Signed out", + "message": "This Claude account is signed out. Re-authenticate it in Settings → Accounts to see its usage." + }, + "renewFailed": { + "message": "Claude could not renew this account’s sign-in. Start a Claude session on it to renew, or re-authenticate it in Settings → Accounts." + } } }, "SshTargetStatusRow": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 5ab6bbaaa5dc..cc22622bfaf4 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3524,6 +3524,20 @@ "message": "Ejecuta cursor-agent login en una terminal del equipo donde se ejecuta Orca y vuelve a intentar consultar el uso." }, "plan": "Plan" + }, + "claude": { + "signedOut": { + "label": "Sesión cerrada", + "message": "Esta cuenta de Claude no tiene la sesión iniciada. Vuelve a autenticarla en Configuración → Cuentas para ver su uso." + }, + "renewFailed": { + "message": "Claude no pudo renovar el inicio de sesión de esta cuenta. Inicia una sesión de Claude con ella para renovarlo o vuelve a autenticarla en Configuración → Cuentas." + } + }, + "minimax": { + "expired": { + "label": "Inicio de sesión caducado" + } } }, "SshTargetStatusRow": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 6465f23c87ec..fb20b9ee6e78 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -3971,6 +3971,15 @@ "message": "Exécutez cursor-agent login dans un terminal sur l'ordinateur qui exécute Orca, puis réessayez." }, "plan": "Forfait" + }, + "claude": { + "signedOut": { + "label": "Déconnecté", + "message": "Ce compte Claude est déconnecté. Réauthentifiez-le dans Paramètres → Comptes pour voir son utilisation." + }, + "renewFailed": { + "message": "Claude n’a pas pu renouveler la connexion de ce compte. Lancez une session Claude sur ce compte pour la renouveler, ou réauthentifiez-le dans Paramètres → Comptes." + } } }, "SshTargetStatusRow": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 4af40935b246..bbcae54fb7a0 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3860,6 +3860,15 @@ "message": "Orca を実行しているコンピュータのターミナルで cursor-agent login を実行し、使用状況を再試行してください。" }, "plan": "プラン" + }, + "claude": { + "signedOut": { + "label": "サインアウト済み", + "message": "この Claude アカウントはサインアウトしています。使用状況を表示するには、設定 → アカウントで再認証してください。" + }, + "renewFailed": { + "message": "Claude はこのアカウントのサインインを更新できませんでした。このアカウントで Claude セッションを開始して更新するか、設定 → アカウントで再認証してください。" + } } }, "SshTargetStatusRow": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a72fbaa9eaa6..751d5005f2b8 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3860,6 +3860,15 @@ "message": "Orca가 실행 중인 컴퓨터의 터미널에서 cursor-agent login을 실행한 뒤 사용량을 다시 시도하세요." }, "plan": "플랜" + }, + "claude": { + "signedOut": { + "label": "로그아웃됨", + "message": "이 Claude 계정은 로그아웃되었습니다. 사용량을 보려면 설정 → 계정에서 다시 인증하세요." + }, + "renewFailed": { + "message": "Claude가 이 계정의 로그인을 갱신하지 못했습니다. 이 계정으로 Claude 세션을 시작해 갱신하거나 설정 → 계정에서 다시 인증하세요." + } } }, "SshTargetStatusRow": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index cf73cbb7d8ff..fb06b47bf29c 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3860,6 +3860,15 @@ "message": "请在运行 Orca 的电脑上的终端中运行 cursor-agent login,然后重新刷新用量。" }, "plan": "套餐" + }, + "claude": { + "signedOut": { + "label": "已退出登录", + "message": "此 Claude 账户已退出登录。请在“设置 → 账户”中重新验证以查看其用量。" + }, + "renewFailed": { + "message": "Claude 无法续期此账户的登录。请用此账户启动 Claude 会话来续期,或在“设置 → 账户”中重新验证。" + } } }, "SshTargetStatusRow": { diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 6b06f09d10e9..2e3285ab5db4 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -21,6 +21,7 @@ export type UsageRateLimitFailureKind = | 'missing-credentials' | 'stale-token' | 'refreshable-credentials-without-token' + /** Orca cannot renew the login; the user must run the provider's CLI or sign in again. */ | 'delegated-refresh-required' | 'deferred-by-live-session' | 'keychain-unavailable' From 6e7e8365a4312ffc5768c052d2627becd0983429 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:59:12 -0700 Subject: [PATCH 3/4] fix(rate-limits): report an unlaunchable Claude as unavailable, and name the real Settings page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A Claude binary that is missing or can't be executed fails asynchronously, after the connection opens. The refresh now reads the connection's processless verdict and reports "not launched" instead of a failed login refresh: the meter says usage is unavailable rather than "Sign-in expired", and a 15-minute per-binary backoff stops a spawn attempt every poll. - The signed-out and renewal tooltips point to Settings → AI Provider Accounts (the page's real title, localized from the existing sidebar translation) instead of a nonexistent "Accounts". - Fix a stale comment that still described the removed hidden-terminal gate. --- .../claude-cli-login-refresh.test.ts | 37 ++++++++++++++++--- .../rate-limits/claude-cli-login-refresh.ts | 7 ++++ .../claude-fetcher-cli-login-refresh.test.ts | 23 ++++++++++++ src/main/rate-limits/claude-oauth-recovery.ts | 25 +++++++++++-- src/main/rate-limits/service/service-types.ts | 2 +- .../src/components/status-bar/tooltip.test.ts | 4 +- .../components/status-bar/usage-error-copy.ts | 4 +- src/renderer/src/i18n/locales/en.json | 4 +- src/renderer/src/i18n/locales/es.json | 4 +- src/renderer/src/i18n/locales/fr.json | 4 +- src/renderer/src/i18n/locales/ja.json | 4 +- src/renderer/src/i18n/locales/ko.json | 4 +- src/renderer/src/i18n/locales/zh.json | 4 +- 13 files changed, 100 insertions(+), 26 deletions(-) diff --git a/src/main/rate-limits/claude-cli-login-refresh.test.ts b/src/main/rate-limits/claude-cli-login-refresh.test.ts index a527e5f34af5..3914c4a8782b 100644 --- a/src/main/rate-limits/claude-cli-login-refresh.test.ts +++ b/src/main/rate-limits/claude-cli-login-refresh.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from '../claude-accounts/live-pty-gate' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import type { + ClaudeChildExitVerdict, ClaudeStreamJsonConnection, ClaudeStreamJsonConnectionHandlers, ClaudeStreamJsonLaunch @@ -23,23 +24,32 @@ const authPreparation: ClaudeRuntimeAuthPreparation = { } const stillSelected = (): string => authPreparation.provenance -function fakeConnect(getUsage: (handlers: ClaudeStreamJsonConnectionHandlers) => Promise) { +function fakeConnect( + getUsage: (handlers: ClaudeStreamJsonConnectionHandlers) => Promise, + root: ClaudeChildExitVerdict['root'] = 'exited' +) { const seen: { launch?: ClaudeStreamJsonLaunch; closes: number; usageTimeout?: number } = { closes: 0 } const connect = vi.fn( async (launch: ClaudeStreamJsonLaunch, handlers: ClaudeStreamJsonConnectionHandlers = {}) => { seen.launch = launch + // Memoized like the real close(), so closes counts children closed, not calls. + let closing: Promise | null = null // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the refresh only calls getUsage and close. return { getUsage: (options?: { timeoutMs?: number }) => { seen.usageTimeout = options?.timeoutMs return getUsage(handlers) }, - close: async () => { - seen.closes += 1 - return true - } + close: () => { + closing ??= (async () => { + seen.closes += 1 + return true + })() + return closing + }, + exitVerdict: { root, tree: 'exited' } } as unknown as ClaudeStreamJsonConnection } ) @@ -123,6 +133,23 @@ describe('refreshClaudeLoginViaCli', () => { expect(seen.closes).toBe(1) }) + it('reports a binary that could not be launched as not launched, not as a failed refresh', async () => { + const { connect, seen } = fakeConnect(async (handlers) => { + handlers.onFault?.(new Error('spawn /missing/claude ENOENT')) + throw new Error('Query closed before response received') + }, 'processless') + + await expect( + refreshClaudeLoginViaCli({ + authPreparation, + readCurrentAuthProvenance: stillSelected, + connect, + resolveCommand: () => '/missing/claude' + }) + ).resolves.toEqual({ kind: 'not-launched', message: 'spawn /missing/claude ENOENT' }) + expect(seen.closes).toBeGreaterThanOrEqual(1) + }) + it('closes the child when the fetch is aborted mid-request', async () => { const controller = new AbortController() const { connect, seen } = fakeConnect(async () => { diff --git a/src/main/rate-limits/claude-cli-login-refresh.ts b/src/main/rate-limits/claude-cli-login-refresh.ts index f01f4f5694dc..587e9f76b1c0 100644 --- a/src/main/rate-limits/claude-cli-login-refresh.ts +++ b/src/main/rate-limits/claude-cli-login-refresh.ts @@ -25,6 +25,8 @@ export type ClaudeCliLoginRefreshOutcome = | { kind: 'failed'; message: string } /** Orca never started Claude, so the stored login cannot have changed because of it. */ | { kind: 'not-started'; message: string } + /** The binary could not be launched (missing or not executable); no Claude ever ran. */ + | { kind: 'not-launched'; message: string } type ConnectClaude = typeof openClaudeStreamJsonConnection @@ -115,6 +117,11 @@ export async function refreshClaudeLoginViaCli(input: { return { kind: 'answered' } } catch (error) { const message = error instanceof Error ? error.message : String(error) + // Spawn errors arrive asynchronously after connect resolves; close() settles the verdict. + await connection.close() + if (connection.exitVerdict.root === 'processless') { + return { kind: 'not-launched', message: fault.first?.message ?? message } + } if (isUnsupportedSubtype(message)) { return { kind: 'unsupported', message } } diff --git a/src/main/rate-limits/claude-fetcher-cli-login-refresh.test.ts b/src/main/rate-limits/claude-fetcher-cli-login-refresh.test.ts index 9932e81e13ec..bc7cef4defbc 100644 --- a/src/main/rate-limits/claude-fetcher-cli-login-refresh.test.ts +++ b/src/main/rate-limits/claude-fetcher-cli-login-refresh.test.ts @@ -239,6 +239,29 @@ describe('fetchClaudeRateLimits without a hidden interactive Claude', () => { expect(refreshClaudeLoginViaCli).toHaveBeenCalledTimes(1) }) + it('reports Claude as unavailable, and backs off, when its binary cannot be launched', async () => { + vi.mocked(refreshClaudeLoginViaCli).mockResolvedValue({ + kind: 'not-launched', + message: 'spawn /fake/bin/claude ENOENT' + }) + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue( + storedLogin('stale-oauth-token') + ) + netFetchMock.mockResolvedValue(usageError(401, 'authentication_error')) + const options = { authPreparation: managedAuth(), cliLoginRefresh: refreshPermit() } + + // Claude never ran, so nothing was learned about the login. + await expect(fetchClaudeRateLimits(options)).resolves.toMatchObject({ + status: 'error', + usageMetadata: { failureKind: 'cli-unavailable' } + }) + await expect(fetchClaudeRateLimits(options)).resolves.toMatchObject({ + usageMetadata: { failureKind: 'cli-unavailable' } + }) + + expect(refreshClaudeLoginViaCli).toHaveBeenCalledTimes(1) + }) + it('does not start a CLI that lacks get_usage again', async () => { vi.mocked(refreshClaudeLoginViaCli).mockResolvedValue({ kind: 'unsupported', diff --git a/src/main/rate-limits/claude-oauth-recovery.ts b/src/main/rate-limits/claude-oauth-recovery.ts index 9e015af15601..1245625fc6f5 100644 --- a/src/main/rate-limits/claude-oauth-recovery.ts +++ b/src/main/rate-limits/claude-oauth-recovery.ts @@ -119,11 +119,13 @@ export function makeClaudeUsageClassificationError(input: { }) } -// Both latches die on their own: a login that failed to refresh is skipped until its stored +// Every latch dies on its own: a login that failed to refresh is skipped until its stored // credentials change or the backoff ends (a dead login and a network blip look the same from -// here), and a CLI without get_usage until the installed binary changes. +// here), a binary that could not launch until the backoff ends, and a CLI without get_usage +// until the installed binary changes. const FAILED_LOGIN_REFRESH_BACKOFF_MS = 15 * 60_000 const failedLoginRefreshByProvenance = new Map() +const unlaunchableCliBinaryRetryAtMs = new Map() const unsupportedCliBinaries = new Set() function credentialStateKey(credentials: ClaudeOAuthCredentialReadResult): string { @@ -142,6 +144,7 @@ function claudeBinaryKey(command: string): string { /** Test seam: the latches are process-wide. */ export function resetClaudeLoginRefreshLatchesForTests(): void { failedLoginRefreshByProvenance.clear() + unlaunchableCliBinaryRetryAtMs.clear() unsupportedCliBinaries.clear() } @@ -151,6 +154,8 @@ export type ClaudeLoginRepair = | { kind: 'not-renewed' } /** Nothing was learned about the login; report the original failure. */ | { kind: 'unresolved' } + /** Claude could not be launched, so the login's state is unknown. */ + | { kind: 'cli-unavailable' } /** * Lets the account's own Claude CLI refresh its expired login, then retries the usage endpoint @@ -179,6 +184,9 @@ export async function repairClaudeCredentialsThenRetryOAuth(input: { if (unsupportedCliBinaries.has(binary)) { return { kind: 'not-renewed' } } + if (Date.now() < (unlaunchableCliBinaryRetryAtMs.get(binary) ?? 0)) { + return { kind: 'cli-unavailable' } + } recordClaudeUsageAttempt(input.attempts, 'cli') const outcome = await refreshClaudeLoginViaCli({ authPreparation, @@ -201,6 +209,11 @@ export async function repairClaudeCredentialsThenRetryOAuth(input: { // Claude never ran, so there is nothing to re-read and no reason to back off. return { kind: 'unresolved' } } + if (outcome.kind === 'not-launched') { + // Backs off so a missing binary is not spawned on every poll. + unlaunchableCliBinaryRetryAtMs.set(binary, Date.now() + FAILED_LOGIN_REFRESH_BACKOFF_MS) + return { kind: 'cli-unavailable' } + } if (outcome.kind === 'unsupported') { unsupportedCliBinaries.add(binary) return { kind: 'not-renewed' } @@ -247,11 +260,15 @@ export async function repairClaudeCredentialsThenRetryOAuth(input: { /** * After Claude failed to renew the login, only the user can: by running Claude on the account - * (it renews or asks them to sign in) or re-authenticating it. + * (it renews or asks them to sign in) or re-authenticating it. A Claude that never launched + * says nothing about the login, so that is reported as Claude being unavailable instead. */ export function failureKindAfterClaudeLoginRepair( repair: ClaudeLoginRepair, failureKind: UsageRateLimitFailureKind ): UsageRateLimitFailureKind { - return repair.kind === 'not-renewed' ? 'delegated-refresh-required' : failureKind + if (repair.kind === 'not-renewed') { + return 'delegated-refresh-required' + } + return repair.kind === 'cli-unavailable' ? 'cli-unavailable' : failureKind } diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts index 215dbe5aea77..523572f32810 100644 --- a/src/main/rate-limits/service/service-types.ts +++ b/src/main/rate-limits/service/service-types.ts @@ -125,7 +125,7 @@ export function normalizePollingInterval(ms: number): number { export function isSystemDefaultClaudeAuth( authPreparation: ClaudeRuntimeAuthPreparation | undefined ): boolean { - // Why: fetch cycles treat missing Claude auth as system-default; align the PTY gate so refresh can't trigger auth flows. + // Why: fetch cycles treat missing Claude auth as system-default, so the headless login refresh never starts Claude for the user's own login. if (!authPreparation) { return true } diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index d3e5d670b71e..fee9c6ca8fb7 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -272,7 +272,7 @@ describe('provider usage error copy', () => { expect(getProviderUsageStatusLabel(p)).toBe('Sign-in expired') expect(getProviderUsageErrorMessage(p)).toBe( - 'Claude could not renew this account’s sign-in. Start a Claude session on it to renew, or re-authenticate it in Settings → Accounts.' + 'Claude could not renew this account’s sign-in. Start a Claude session on it to renew, or re-authenticate it in Settings → AI Provider Accounts.' ) }) @@ -284,7 +284,7 @@ describe('provider usage error copy', () => { expect(getProviderUsageStatusLabel(p)).toBe('Signed out') expect(getProviderUsageErrorMessage(p)).toBe( - 'This Claude account is signed out. Re-authenticate it in Settings → Accounts to see its usage.' + 'This Claude account is signed out. Re-authenticate it in Settings → AI Provider Accounts to see its usage.' ) }) diff --git a/src/renderer/src/components/status-bar/usage-error-copy.ts b/src/renderer/src/components/status-bar/usage-error-copy.ts index db91b75e5a9a..0e964c5b5145 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.ts @@ -192,12 +192,12 @@ export function getProviderUsageErrorMessage(p: ProviderRateLimits): string { case 'delegated-refresh-required': return translate( 'auto.components.status.bar.tooltip.claude.renewFailed.message', - 'Claude could not renew this account’s sign-in. Start a Claude session on it to renew, or re-authenticate it in Settings → Accounts.' + 'Claude could not renew this account’s sign-in. Start a Claude session on it to renew, or re-authenticate it in Settings → AI Provider Accounts.' ) case 'missing-credentials': return translate( 'auto.components.status.bar.tooltip.claude.signedOut.message', - 'This Claude account is signed out. Re-authenticate it in Settings → Accounts to see its usage.' + 'This Claude account is signed out. Re-authenticate it in Settings → AI Provider Accounts to see its usage.' ) case 'missing-scope': return p.error diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7e1509923064..5b605adce064 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4042,10 +4042,10 @@ "claude": { "signedOut": { "label": "Signed out", - "message": "This Claude account is signed out. Re-authenticate it in Settings → Accounts to see its usage." + "message": "This Claude account is signed out. Re-authenticate it in Settings → AI Provider Accounts to see its usage." }, "renewFailed": { - "message": "Claude could not renew this account’s sign-in. Start a Claude session on it to renew, or re-authenticate it in Settings → Accounts." + "message": "Claude could not renew this account’s sign-in. Start a Claude session on it to renew, or re-authenticate it in Settings → AI Provider Accounts." } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index cc22622bfaf4..a7cde0879442 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3528,10 +3528,10 @@ "claude": { "signedOut": { "label": "Sesión cerrada", - "message": "Esta cuenta de Claude no tiene la sesión iniciada. Vuelve a autenticarla en Configuración → Cuentas para ver su uso." + "message": "Esta cuenta de Claude no tiene la sesión iniciada. Vuelve a autenticarla en Configuración → Cuentas de proveedores de IA para ver su uso." }, "renewFailed": { - "message": "Claude no pudo renovar el inicio de sesión de esta cuenta. Inicia una sesión de Claude con ella para renovarlo o vuelve a autenticarla en Configuración → Cuentas." + "message": "Claude no pudo renovar el inicio de sesión de esta cuenta. Inicia una sesión de Claude con ella para renovarlo o vuelve a autenticarla en Configuración → Cuentas de proveedores de IA." } }, "minimax": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index fb20b9ee6e78..eb15c299af2b 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -3975,10 +3975,10 @@ "claude": { "signedOut": { "label": "Déconnecté", - "message": "Ce compte Claude est déconnecté. Réauthentifiez-le dans Paramètres → Comptes pour voir son utilisation." + "message": "Ce compte Claude est déconnecté. Réauthentifiez-le dans Paramètres → Comptes de fournisseurs d'IA pour voir son utilisation." }, "renewFailed": { - "message": "Claude n’a pas pu renouveler la connexion de ce compte. Lancez une session Claude sur ce compte pour la renouveler, ou réauthentifiez-le dans Paramètres → Comptes." + "message": "Claude n’a pas pu renouveler la connexion de ce compte. Lancez une session Claude sur ce compte pour la renouveler, ou réauthentifiez-le dans Paramètres → Comptes de fournisseurs d'IA." } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index bbcae54fb7a0..a49b0754fbce 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3864,10 +3864,10 @@ "claude": { "signedOut": { "label": "サインアウト済み", - "message": "この Claude アカウントはサインアウトしています。使用状況を表示するには、設定 → アカウントで再認証してください。" + "message": "この Claude アカウントはサインアウトしています。使用状況を表示するには、設定 → AI プロバイダーアカウントで再認証してください。" }, "renewFailed": { - "message": "Claude はこのアカウントのサインインを更新できませんでした。このアカウントで Claude セッションを開始して更新するか、設定 → アカウントで再認証してください。" + "message": "Claude はこのアカウントのサインインを更新できませんでした。このアカウントで Claude セッションを開始して更新するか、設定 → AI プロバイダーアカウントで再認証してください。" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 751d5005f2b8..f0bfd213ca22 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3864,10 +3864,10 @@ "claude": { "signedOut": { "label": "로그아웃됨", - "message": "이 Claude 계정은 로그아웃되었습니다. 사용량을 보려면 설정 → 계정에서 다시 인증하세요." + "message": "이 Claude 계정은 로그아웃되었습니다. 사용량을 보려면 설정 → AI 제공자 계정에서 다시 인증하세요." }, "renewFailed": { - "message": "Claude가 이 계정의 로그인을 갱신하지 못했습니다. 이 계정으로 Claude 세션을 시작해 갱신하거나 설정 → 계정에서 다시 인증하세요." + "message": "Claude가 이 계정의 로그인을 갱신하지 못했습니다. 이 계정으로 Claude 세션을 시작해 갱신하거나 설정 → AI 제공자 계정에서 다시 인증하세요." } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index fb06b47bf29c..9e0532093cde 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3864,10 +3864,10 @@ "claude": { "signedOut": { "label": "已退出登录", - "message": "此 Claude 账户已退出登录。请在“设置 → 账户”中重新验证以查看其用量。" + "message": "此 Claude 账户已退出登录。请在“设置 → AI 提供商账户”中重新验证以查看其用量。" }, "renewFailed": { - "message": "Claude 无法续期此账户的登录。请用此账户启动 Claude 会话来续期,或在“设置 → 账户”中重新验证。" + "message": "Claude 无法续期此账户的登录。请用此账户启动 Claude 会话来续期,或在“设置 → AI 提供商账户”中重新验证。" } } }, From 91aa11640ba8000363485b18ba925835cce9b26d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:10:01 -0700 Subject: [PATCH 4/4] fix(rate-limits): check for a runnable Claude before the login refresh launches it On macOS and Linux the launch goes through a supervisor process that always starts, so a missing Claude binary only showed up as the supervisor exiting, never as the "processless" verdict the previous fix read. The refresh now checks the resolved command before launching: a bare name (the resolver found nothing) or a path that is not an executable file is reported as not launched, without spawning anything, and keeps the per-binary backoff and the "usage unavailable" reporting. The after-the-fact verdict read and its extra close() are gone, so each connection is closed exactly once. --- .../claude-cli-login-refresh.test.ts | 87 +++++++++++-------- .../rate-limits/claude-cli-login-refresh.ts | 16 ++-- src/shared/node-cli-command-resolution.ts | 2 +- 3 files changed, 59 insertions(+), 46 deletions(-) diff --git a/src/main/rate-limits/claude-cli-login-refresh.test.ts b/src/main/rate-limits/claude-cli-login-refresh.test.ts index 3914c4a8782b..afaa5a9b0043 100644 --- a/src/main/rate-limits/claude-cli-login-refresh.test.ts +++ b/src/main/rate-limits/claude-cli-login-refresh.test.ts @@ -1,8 +1,10 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from '../claude-accounts/live-pty-gate' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import type { - ClaudeChildExitVerdict, ClaudeStreamJsonConnection, ClaudeStreamJsonConnectionHandlers, ClaudeStreamJsonLaunch @@ -24,32 +26,28 @@ const authPreparation: ClaudeRuntimeAuthPreparation = { } const stillSelected = (): string => authPreparation.provenance -function fakeConnect( - getUsage: (handlers: ClaudeStreamJsonConnectionHandlers) => Promise, - root: ClaudeChildExitVerdict['root'] = 'exited' -) { +// The refresh refuses to launch anything but a real executable file. +const binDir = mkdtempSync(join(tmpdir(), 'orca-claude-login-refresh-')) +const claudeBin = join(binDir, 'claude') +writeFileSync(claudeBin, '#!/bin/sh\n', { mode: 0o755 }) + +function fakeConnect(getUsage: (handlers: ClaudeStreamJsonConnectionHandlers) => Promise) { const seen: { launch?: ClaudeStreamJsonLaunch; closes: number; usageTimeout?: number } = { closes: 0 } const connect = vi.fn( async (launch: ClaudeStreamJsonLaunch, handlers: ClaudeStreamJsonConnectionHandlers = {}) => { seen.launch = launch - // Memoized like the real close(), so closes counts children closed, not calls. - let closing: Promise | null = null // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the refresh only calls getUsage and close. return { getUsage: (options?: { timeoutMs?: number }) => { seen.usageTimeout = options?.timeoutMs return getUsage(handlers) }, - close: () => { - closing ??= (async () => { - seen.closes += 1 - return true - })() - return closing - }, - exitVerdict: { root, tree: 'exited' } + close: async () => { + seen.closes += 1 + return true + } } as unknown as ClaudeStreamJsonConnection } ) @@ -61,6 +59,10 @@ describe('refreshClaudeLoginViaCli', () => { endClaudeAuthSwitch() }) + afterAll(() => { + rmSync(binDir, { recursive: true, force: true }) + }) + it('asks the account’s own Claude for usage in an isolated session-less child, then closes it', async () => { vi.stubEnv('ANTHROPIC_API_KEY', 'ambient-key') const { connect, seen } = fakeConnect(async () => ({ rate_limits_available: true })) @@ -71,12 +73,12 @@ describe('refreshClaudeLoginViaCli', () => { readCurrentAuthProvenance: stillSelected, networkProxySettings: { httpProxyUrl: 'http://proxy.test:8080' }, connect, - resolveCommand: () => '/fake/bin/claude' + resolveCommand: () => claudeBin }) ).resolves.toEqual({ kind: 'answered' }) expect(seen.launch).toMatchObject({ - pathToClaudeCodeExecutable: '/fake/bin/claude', + pathToClaudeCodeExecutable: claudeBin, cwd: '/tmp/orca-test/rate-limit-pty-cwd', options: { settingSources: ['user'], @@ -107,7 +109,7 @@ describe('refreshClaudeLoginViaCli', () => { authPreparation, readCurrentAuthProvenance: stillSelected, connect, - resolveCommand: () => '/fake/claude' + resolveCommand: () => claudeBin }) ).resolves.toMatchObject({ kind: 'unsupported' }) expect(seen.closes).toBe(1) @@ -124,7 +126,7 @@ describe('refreshClaudeLoginViaCli', () => { authPreparation, readCurrentAuthProvenance: stillSelected, connect, - resolveCommand: () => '/fake/claude' + resolveCommand: () => claudeBin }) ).resolves.toEqual({ kind: 'failed', @@ -133,21 +135,30 @@ describe('refreshClaudeLoginViaCli', () => { expect(seen.closes).toBe(1) }) - it('reports a binary that could not be launched as not launched, not as a failed refresh', async () => { - const { connect, seen } = fakeConnect(async (handlers) => { - handlers.onFault?.(new Error('spawn /missing/claude ENOENT')) - throw new Error('Query closed before response received') - }, 'processless') + it('starts nothing when no runnable Claude binary was found', async () => { + const { connect } = fakeConnect(async () => ({})) + const notExecutable = join(binDir, 'claude-not-executable') + writeFileSync(notExecutable, '#!/bin/sh\n', { mode: 0o644 }) - await expect( - refreshClaudeLoginViaCli({ - authPreparation, - readCurrentAuthProvenance: stillSelected, - connect, - resolveCommand: () => '/missing/claude' + // Windows has no execute bit, so a plain file counts as runnable there. + const commands = ['claude', join(binDir, 'missing-claude'), binDir] + if (process.platform !== 'win32') { + commands.push(notExecutable) + } + for (const command of commands) { + await expect( + refreshClaudeLoginViaCli({ + authPreparation, + readCurrentAuthProvenance: stillSelected, + connect, + resolveCommand: () => command + }) + ).resolves.toEqual({ + kind: 'not-launched', + message: `no runnable Claude CLI at ${command}` }) - ).resolves.toEqual({ kind: 'not-launched', message: 'spawn /missing/claude ENOENT' }) - expect(seen.closes).toBeGreaterThanOrEqual(1) + } + expect(connect).not.toHaveBeenCalled() }) it('closes the child when the fetch is aborted mid-request', async () => { @@ -163,7 +174,7 @@ describe('refreshClaudeLoginViaCli', () => { readCurrentAuthProvenance: stillSelected, connect, signal: controller.signal, - resolveCommand: () => '/fake/claude' + resolveCommand: () => claudeBin }) ).resolves.toMatchObject({ kind: 'failed' }) expect(seen.closes).toBeGreaterThanOrEqual(1) @@ -195,7 +206,7 @@ describe('refreshClaudeLoginViaCli', () => { readCurrentAuthProvenance: stillSelected, connect, signal: controller.signal, - resolveCommand: () => '/fake/claude' + resolveCommand: () => claudeBin }) controller.abort() endClaudeAuthSwitch() @@ -213,7 +224,7 @@ describe('refreshClaudeLoginViaCli', () => { authPreparation, readCurrentAuthProvenance: () => selected, connect, - resolveCommand: () => '/fake/claude' + resolveCommand: () => claudeBin }) // The user switched back to their own login; the runtime home now holds it. selected = 'system' @@ -234,7 +245,7 @@ describe('refreshClaudeLoginViaCli', () => { authPreparation, readCurrentAuthProvenance: () => 'managed:account-2', connect, - resolveCommand: () => '/fake/claude' + resolveCommand: () => claudeBin }) ).resolves.toMatchObject({ kind: 'not-started' }) expect(connect).not.toHaveBeenCalled() @@ -250,7 +261,7 @@ describe('refreshClaudeLoginViaCli', () => { authPreparation, readCurrentAuthProvenance: stillSelected, connect, - resolveCommand: () => '/fake/claude' + resolveCommand: () => claudeBin }) ).resolves.toEqual({ kind: 'not-started', diff --git a/src/main/rate-limits/claude-cli-login-refresh.ts b/src/main/rate-limits/claude-cli-login-refresh.ts index 587e9f76b1c0..8d2089955553 100644 --- a/src/main/rate-limits/claude-cli-login-refresh.ts +++ b/src/main/rate-limits/claude-cli-login-refresh.ts @@ -1,5 +1,6 @@ +import { isAbsolute } from 'node:path' import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy' -import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' +import { isRunnableCommand, withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import { applyClaudeEnvPatch } from '../claude-accounts/environment' import { isClaudeAuthSwitchInProgress, @@ -25,7 +26,7 @@ export type ClaudeCliLoginRefreshOutcome = | { kind: 'failed'; message: string } /** Orca never started Claude, so the stored login cannot have changed because of it. */ | { kind: 'not-started'; message: string } - /** The binary could not be launched (missing or not executable); no Claude ever ran. */ + /** No runnable Claude binary was found (missing or not executable), so nothing was started. */ | { kind: 'not-launched'; message: string } type ConnectClaude = typeof openClaudeStreamJsonConnection @@ -67,6 +68,12 @@ export async function refreshClaudeLoginViaCli(input: { return { kind: 'not-started', message: 'the selected Claude account changed' } } const command = (input.resolveCommand ?? resolveClaudeCommand)() + // Why: on POSIX the spawn goes through a supervisor that always starts, so a missing binary + // only shows up as the supervisor's exit; check before launching instead. The resolver + // returns the bare name when it found no runnable file. + if (!isAbsolute(command) || !isRunnableCommand(process.platform, command)) { + return { kind: 'not-launched', message: `no runnable Claude CLI at ${command}` } + } const launch: ClaudeStreamJsonLaunch = { pathToClaudeCodeExecutable: command, // No user message is ever sent, so nothing reaches the model; these keep the session-less @@ -117,11 +124,6 @@ export async function refreshClaudeLoginViaCli(input: { return { kind: 'answered' } } catch (error) { const message = error instanceof Error ? error.message : String(error) - // Spawn errors arrive asynchronously after connect resolves; close() settles the verdict. - await connection.close() - if (connection.exitVerdict.root === 'processless') { - return { kind: 'not-launched', message: fault.first?.message ?? message } - } if (isUnsupportedSubtype(message)) { return { kind: 'unsupported', message } } diff --git a/src/shared/node-cli-command-resolution.ts b/src/shared/node-cli-command-resolution.ts index c7407ac5eb13..628c2f765642 100644 --- a/src/shared/node-cli-command-resolution.ts +++ b/src/shared/node-cli-command-resolution.ts @@ -71,7 +71,7 @@ function findFirstExecutable( return null } -function isRunnableCommand(platform: NodeJS.Platform, candidate: string): boolean { +export function isRunnableCommand(platform: NodeJS.Platform, candidate: string): boolean { try { const stats = statSync(candidate) if (!stats.isFile()) {