Diagnose Claude usage refresh auth failures (#5614)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-06-17 13:05:32 -07:00
committed by GitHub
co-authored by Orca
parent be6b84e7a6
commit 03c18c4373
5 changed files with 143 additions and 15 deletions
@@ -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 = {
+75 -13
View File
@@ -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', () => {