mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 00:03:20 +00:00
Fall back to legacy keychain when stale scoped Claude credentials 401
Claude Code maintains the legacy 'Claude Code-credentials' Keychain item for the default config dir, but a scoped item (service suffixed with sha256(configDir)) can be left behind by sessions that ran with CLAUDE_CONFIG_DIR set. Once its access token expires, nothing refreshes it: readFromKeychain returned the scoped token unconditionally, every usage fetch 401'd as 'stale-token', and system-default auth has no CLI recovery lane — so the status bar showed 'Refreshing sign-in' forever while the legacy item held a perfectly valid token. Two changes: - readFromKeychain: an actual access token from the legacy item now beats scoped refresh-only credentials (Orca cannot refresh tokens itself, so refresh-only must not shadow a working token). - On a stale-token OAuth failure with scoped-keychain credentials, retry once with the legacy item's token before classifying the failure. Host system-default auth only — managed/WSL credentials never fall back to the host user's legacy keychain item. The legacy retry runs before CLI repair, so a readable working token is preferred over launching a hidden 25s claude PTY.
This commit is contained in:
@@ -178,6 +178,104 @@ describe('fetchClaudeRateLimits', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the legacy keychain token when the scoped token is rejected as stale', async () => {
|
||||
const configDir = '/Users/test/.claude'
|
||||
const authPreparation: ClaudeRuntimeAuthPreparation = {
|
||||
configDir,
|
||||
envPatch: { CLAUDE_CONFIG_DIR: configDir },
|
||||
stripAuthEnv: false,
|
||||
provenance: 'system'
|
||||
}
|
||||
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (dir) =>
|
||||
dir
|
||||
? JSON.stringify({
|
||||
claudeAiOauth: { accessToken: 'stale-scoped-token', refreshToken: 'refresh-1' }
|
||||
})
|
||||
: JSON.stringify({ claudeAiOauth: { accessToken: 'fresh-legacy-token' } })
|
||||
)
|
||||
netFetchMock.mockImplementation(async (_url: string, init?: RequestInit) => {
|
||||
const auth = (init?.headers as Record<string, string> | undefined)?.Authorization
|
||||
return auth === 'Bearer fresh-legacy-token'
|
||||
? new Response(
|
||||
JSON.stringify({ five_hour: { utilization: 12 }, seven_day: { utilization: 34 } }),
|
||||
{ status: 200 }
|
||||
)
|
||||
: new Response(
|
||||
JSON.stringify({ error: { message: 'Invalid authentication credentials' } }),
|
||||
{ status: 401 }
|
||||
)
|
||||
})
|
||||
|
||||
await expect(
|
||||
fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false })
|
||||
).resolves.toMatchObject({
|
||||
provider: 'claude',
|
||||
status: 'ok',
|
||||
session: { usedPercent: 12 },
|
||||
weekly: { usedPercent: 34 }
|
||||
})
|
||||
|
||||
expect(netFetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(fetchViaPty).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prefers a legacy access token over scoped refresh-only credentials', async () => {
|
||||
const configDir = '/Users/test/.claude'
|
||||
const authPreparation: ClaudeRuntimeAuthPreparation = {
|
||||
configDir,
|
||||
envPatch: { CLAUDE_CONFIG_DIR: configDir },
|
||||
stripAuthEnv: false,
|
||||
provenance: 'system'
|
||||
}
|
||||
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (dir) =>
|
||||
dir
|
||||
? JSON.stringify({ claudeAiOauth: { refreshToken: 'refresh-only' } })
|
||||
: JSON.stringify({ claudeAiOauth: { accessToken: 'fresh-legacy-token' } })
|
||||
)
|
||||
|
||||
await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({
|
||||
provider: 'claude',
|
||||
status: 'ok',
|
||||
session: { usedPercent: 12 }
|
||||
})
|
||||
|
||||
expect(netFetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(netFetchMock).toHaveBeenCalledWith(
|
||||
'https://api.anthropic.com/api/oauth/usage',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer fresh-legacy-token' })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not retry with the legacy keychain for managed account credentials', async () => {
|
||||
const configDir = '/Users/test/managed-account'
|
||||
const authPreparation: ClaudeRuntimeAuthPreparation = {
|
||||
configDir,
|
||||
envPatch: { CLAUDE_CONFIG_DIR: configDir },
|
||||
stripAuthEnv: true,
|
||||
provenance: 'managed:account-1'
|
||||
}
|
||||
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (dir) =>
|
||||
dir ? JSON.stringify({ claudeAiOauth: { accessToken: 'stale-managed-token' } }) : null
|
||||
)
|
||||
netFetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: { message: 'Invalid authentication credentials' } }), {
|
||||
status: 401
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false })
|
||||
).resolves.toMatchObject({
|
||||
provider: 'claude',
|
||||
status: 'error'
|
||||
})
|
||||
|
||||
expect(netFetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(readActiveClaudeKeychainCredentialsStrict).not.toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('accepts Claude Code statusline-style rate limit window fields', async () => {
|
||||
const configDir = '/Users/test/.claude'
|
||||
const authPreparation: ClaudeRuntimeAuthPreparation = {
|
||||
@@ -734,6 +832,9 @@ describe('fetchClaudeRateLimits', () => {
|
||||
}
|
||||
})
|
||||
)
|
||||
// Legacy item absent — the stale-scoped legacy fallback must not preempt
|
||||
// CLI repair in this scenario.
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
|
||||
@@ -158,13 +158,16 @@ async function readFromKeychain(configDir?: string): Promise<OAuthCredentialRead
|
||||
if (scopedCredentials.token) {
|
||||
return scopedCredentials
|
||||
}
|
||||
if (scopedCredentials.hasRefreshableCredentials) {
|
||||
return scopedCredentials
|
||||
}
|
||||
const legacyCredentials = await readCredentialsFromStrictKeychain(undefined, 'legacy-keychain')
|
||||
// Why: Orca cannot refresh tokens itself, so an actual access token from
|
||||
// either item beats refresh-only credentials. A scoped item the CLI stopped
|
||||
// maintaining must not shadow a still-working legacy token.
|
||||
if (legacyCredentials.token) {
|
||||
return legacyCredentials
|
||||
}
|
||||
if (scopedCredentials.hasRefreshableCredentials) {
|
||||
return scopedCredentials
|
||||
}
|
||||
if (legacyCredentials.hasRefreshableCredentials) {
|
||||
return legacyCredentials
|
||||
}
|
||||
@@ -637,6 +640,84 @@ async function supplementOAuthUsageFromCli(input: {
|
||||
}
|
||||
}
|
||||
|
||||
async function completeOAuthUsageSuccess(input: {
|
||||
oauthLimits: ProviderRateLimits
|
||||
oauthCredentials: OAuthCredentialReadResult
|
||||
attempts: ClaudeUsageAttemptState
|
||||
options?: FetchClaudeRateLimitsOptions
|
||||
}): Promise<ProviderRateLimits> {
|
||||
const limits = await supplementOAuthUsageFromCli({
|
||||
oauthLimits: input.oauthLimits,
|
||||
authPreparation: input.options?.authPreparation,
|
||||
oauthCredentials: input.oauthCredentials,
|
||||
attempts: input.attempts,
|
||||
networkProxySettings: input.options?.networkProxySettings,
|
||||
allowUsagePanelSupplement:
|
||||
input.options?.allowUsagePanelSupplement ??
|
||||
isManagedClaudeAuth(input.options?.authPreparation),
|
||||
signal: input.options?.signal
|
||||
})
|
||||
if (input.options?.signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
return withClaudeUsageMetadata(
|
||||
limits,
|
||||
metadataForAttempt({
|
||||
attemptedSources: input.attempts.attemptedSources,
|
||||
oauthCredentials: input.oauthCredentials,
|
||||
authPreparation: input.options?.authPreparation,
|
||||
source: 'oauth'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function canRetryWithLegacyKeychainToken(input: {
|
||||
classification: ClaudeUsageErrorClassification
|
||||
oauthCredentials: OAuthCredentialReadResult
|
||||
authPreparation?: ClaudeRuntimeAuthPreparation
|
||||
}): boolean {
|
||||
// Why: the CLI only maintains the legacy keychain item for the default config
|
||||
// dir, so a scoped item can hold a token that expired long ago and will 401
|
||||
// on every fetch with no recovery path. Host system-default auth may retry
|
||||
// with the legacy item; managed/WSL credentials must never be answered with
|
||||
// the host user's legacy keychain account.
|
||||
return (
|
||||
input.classification.failureKind === 'stale-token' &&
|
||||
input.oauthCredentials.source === 'scoped-keychain' &&
|
||||
(input.authPreparation?.runtime ?? 'host') === 'host' &&
|
||||
!isManagedClaudeAuth(input.authPreparation)
|
||||
)
|
||||
}
|
||||
|
||||
async function retryOAuthWithLegacyKeychainToken(input: {
|
||||
failedToken: string | null
|
||||
attempts: ClaudeUsageAttemptState
|
||||
options?: FetchClaudeRateLimitsOptions
|
||||
}): Promise<ProviderRateLimits | null> {
|
||||
const legacyCredentials = await readCredentialsFromStrictKeychain(undefined, 'legacy-keychain')
|
||||
if (!legacyCredentials.token || legacyCredentials.token === input.failedToken) {
|
||||
return null
|
||||
}
|
||||
if (input.options?.signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
try {
|
||||
const oauthLimits = await fetchViaOAuth(legacyCredentials.token, input.options?.signal)
|
||||
if (input.options?.signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
return await completeOAuthUsageSuccess({
|
||||
oauthLimits,
|
||||
oauthCredentials: legacyCredentials,
|
||||
attempts: input.attempts,
|
||||
options: input.options
|
||||
})
|
||||
} catch (err) {
|
||||
warnClaudeUsageFetchFailure(input.options?.authPreparation, legacyCredentials, err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function shouldDeferForLiveClaude(
|
||||
authPreparation: ClaudeRuntimeAuthPreparation | undefined,
|
||||
classification: ClaudeUsageErrorClassification
|
||||
@@ -798,32 +879,28 @@ export async function fetchClaudeRateLimits(
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
const limits = await supplementOAuthUsageFromCli({
|
||||
oauthLimits,
|
||||
authPreparation: options?.authPreparation,
|
||||
oauthCredentials,
|
||||
attempts,
|
||||
networkProxySettings: options?.networkProxySettings,
|
||||
allowUsagePanelSupplement:
|
||||
options?.allowUsagePanelSupplement ?? isManagedClaudeAuth(options?.authPreparation),
|
||||
signal: options?.signal
|
||||
})
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
return withClaudeUsageMetadata(
|
||||
limits,
|
||||
metadataForAttempt({
|
||||
attemptedSources: attempts.attemptedSources,
|
||||
oauthCredentials,
|
||||
authPreparation: options?.authPreparation,
|
||||
source: 'oauth'
|
||||
})
|
||||
)
|
||||
return await completeOAuthUsageSuccess({ oauthLimits, oauthCredentials, attempts, options })
|
||||
} catch (err) {
|
||||
warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, err)
|
||||
const classification = classifyClaudeOAuthUsageError(err)
|
||||
|
||||
if (
|
||||
canRetryWithLegacyKeychainToken({
|
||||
classification,
|
||||
oauthCredentials,
|
||||
authPreparation: options?.authPreparation
|
||||
})
|
||||
) {
|
||||
const legacyResult = await retryOAuthWithLegacyKeychainToken({
|
||||
failedToken: oauthCredentials.token,
|
||||
attempts,
|
||||
options
|
||||
})
|
||||
if (legacyResult) {
|
||||
return legacyResult
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldDeferForLiveClaude(options?.authPreparation, classification)) {
|
||||
return liveClaudeDeferredResult({
|
||||
attempts,
|
||||
|
||||
Reference in New Issue
Block a user