Avoid Claude CLI fallback during rate-limit refresh (#4553)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-03 02:44:46 -04:00 committed by GitHub
parent 9bfceb934f
commit 0f2b918773
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 144 additions and 10 deletions

View File

@ -325,6 +325,102 @@ describe('fetchClaudeRateLimits', () => {
expect(fetchViaPty).not.toHaveBeenCalled()
})
it('does not mask OAuth auth failures with the PTY fallback', async () => {
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
configDir,
envPatch: {},
stripAuthEnv: false,
provenance: 'system'
}
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce(
JSON.stringify({
claudeAiOauth: {
accessToken: 'stale-oauth-token',
refreshToken: 'refresh-token',
expiresAt: Date.now() - 60_000
}
})
)
netFetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
error: {
type: 'authentication_error',
message: 'Invalid OAuth token.'
}
}),
{ status: 401 }
)
)
await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({
provider: 'claude',
status: 'error',
error: 'Invalid OAuth token.'
})
expect(fetchViaPty).not.toHaveBeenCalled()
})
it('does not start the PTY fallback when disabled for background fetches', async () => {
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
configDir,
envPatch: {},
stripAuthEnv: false,
provenance: 'system'
}
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce(
JSON.stringify({
claudeAiOauth: {
accessToken: 'oauth-token',
refreshToken: 'refresh-token',
expiresAt: Date.now() + 60_000
}
})
)
netFetchMock.mockResolvedValueOnce(new Response('temporary failure', { status: 500 }))
await expect(
fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false })
).resolves.toMatchObject({
provider: 'claude',
status: 'error',
error: 'OAuth API returned 500'
})
expect(fetchViaPty).not.toHaveBeenCalled()
})
it('does not start the PTY fallback for refresh-only credentials when disabled', async () => {
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
configDir,
envPatch: {},
stripAuthEnv: false,
provenance: 'system'
}
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce(
JSON.stringify({
claudeAiOauth: {
refreshToken: 'refresh-token',
expiresAt: Date.now() - 60_000
}
})
)
await expect(
fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false })
).resolves.toMatchObject({
provider: 'claude',
status: 'error',
error: 'Claude OAuth access token unavailable'
})
expect(fetchViaPty).not.toHaveBeenCalled()
})
it('does not read inactive managed credentials from unowned auth paths', async () => {
setPlatform('linux')
tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-'))

View File

@ -279,9 +279,14 @@ async function fetchViaOAuth(token: string): Promise<ProviderRateLimits> {
// Public API
// ---------------------------------------------------------------------------
export async function fetchClaudeRateLimits(options?: {
export type FetchClaudeRateLimitsOptions = {
authPreparation?: ClaudeRuntimeAuthPreparation
}): Promise<ProviderRateLimits> {
allowPtyFallback?: boolean
}
export async function fetchClaudeRateLimits(
options?: FetchClaudeRateLimitsOptions
): Promise<ProviderRateLimits> {
if (options?.authPreparation?.runtime === 'wsl' && !options.authPreparation.wslLinuxConfigDir) {
return {
provider: 'claude',
@ -299,13 +304,17 @@ export async function fetchClaudeRateLimits(options?: {
try {
return await fetchViaOAuth(oauthCredentials.token)
} catch (err) {
if (err instanceof OAuthUsageError && err.skipPtyFallback) {
if (
options?.allowPtyFallback === false ||
(err instanceof OAuthUsageError && err.skipPtyFallback)
) {
const message = err instanceof Error ? err.message : 'Unknown error'
return {
provider: 'claude',
session: null,
weekly: null,
updatedAt: Date.now(),
error: withMacTailscaleDnsHint(err.message),
error: withMacTailscaleDnsHint(message),
status: 'error'
}
}
@ -318,6 +327,16 @@ export async function fetchClaudeRateLimits(options?: {
// whose OAuth credentials exist. This remains a fallback for older Claude
// auth shapes and transient OAuth failures.
if (oauthCredentials.token || oauthCredentials.hasRefreshableCredentials) {
if (options?.allowPtyFallback === false) {
return {
provider: 'claude',
session: null,
weekly: null,
updatedAt: Date.now(),
error: 'Claude OAuth access token unavailable',
status: 'error'
}
}
try {
return await fetchViaPty({ authPreparation: options?.authPreparation })
} catch (err) {

View File

@ -12,9 +12,9 @@ export async function createOAuthUsageError(res: Response): Promise<OAuthUsageEr
return new OAuthUsageError(
await describeOAuthUsageError(res),
res.status,
// Why: 429 is already the user-visible Claude usage API answer.
// Falling through to /usage masks it with Claude 2.1's session-stats UI.
res.status === 429
// Why: auth/rate-limit responses are already the user-visible usage API
// answer. Falling through to /usage can spawn Claude Code needlessly.
res.status === 401 || res.status === 403 || res.status === 429
)
}

View File

@ -298,6 +298,10 @@ describe('RateLimitService', () => {
await service.refresh()
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1)
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
authPreparation: undefined,
allowPtyFallback: false
})
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1)
expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1)
expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(1)
@ -400,7 +404,8 @@ describe('RateLimitService', () => {
wslDistro: 'Ubuntu',
wslLinuxConfigDir: '/home/jin/.claude',
stripAuthEnv: true
})
}),
allowPtyFallback: false
})
expect(service.getState().claudeTarget).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' })
})
@ -459,6 +464,10 @@ describe('RateLimitService', () => {
wslDistro: 'Ubuntu'
})
expect(fetchClaudeRateLimits).toHaveBeenLastCalledWith(
expect.objectContaining({ allowPtyFallback: false })
)
expect(service.getState().inactiveClaudeAccounts).not.toEqual(
expect.arrayContaining([expect.objectContaining({ accountId: 'wsl-account-1' })])
)

View File

@ -799,7 +799,12 @@ export class RateLimitService {
? null
: this.getMissingWslCodexHomeResult(codexTarget)
const [claudeResult, codexResult, geminiResult, opencodeGoResult] = await Promise.allSettled([
fetchClaudeRateLimits({ authPreparation: claudeAuthPreparation }),
fetchClaudeRateLimits({
authPreparation: claudeAuthPreparation,
// Why: active quota refreshes run on startup/focus/timers. They must
// never spawn hidden Claude Code, which can trigger macOS App Data TCC.
allowPtyFallback: false
}),
missingWslCodexHome ??
fetchCodexRateLimits({
codexHomePath,
@ -957,7 +962,12 @@ export class RateLimitService {
claude: this.withFetchingStatus(previousState.claude, 'claude')
})
const claude = await fetchClaudeRateLimits({ authPreparation: claudeAuthPreparation }).catch(
const claude = await fetchClaudeRateLimits({
authPreparation: claudeAuthPreparation,
// Why: account-change refreshes share the same automatic active quota
// surface as startup/timer refreshes, so keep them API-only as well.
allowPtyFallback: false
}).catch(
(err): ProviderRateLimits => ({
provider: 'claude',
session: null,