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