diff --git a/packages/core/src/accounts.ts b/packages/core/src/accounts.ts index 4ab389e..23e16e8 100644 --- a/packages/core/src/accounts.ts +++ b/packages/core/src/accounts.ts @@ -82,6 +82,22 @@ export type AccountOperationError = { nextRetryAt?: number retryCount?: number tokenHash?: string + /** + * HTTP status of the underlying refresh/quota failure, when known. Lets + * consumers distinguish a permanently-dead token (400 invalid_grant → + * re-login) from a transient failure (429/5xx → recovers) without a delay + * heuristic. Absent on errors persisted before this field existed. + */ + status?: number + /** + * Explicit dead-token discriminator, set at construction. True ONLY when the + * refresh endpoint returned 400 invalid_grant (token is genuinely dead → + * re-login). False for transient failures AND for retry-exhausted/network + * errors that get a long backoff but are NOT dead — so they are not nagged + * for re-login. Absent on errors persisted before this field existed (those + * fall back to status / the 24h-delay heuristic). + */ + permanent?: boolean } export type AccountQuotaWindow = { @@ -365,6 +381,7 @@ function normalizeOperationError( if (!Number.isFinite(checkedAt)) return undefined const nextRetryAt = Number(value.nextRetryAt) const retryCount = Number(value.retryCount) + const status = Number(value.status) return { message: value.message, checkedAt, @@ -372,6 +389,13 @@ function normalizeOperationError( retryCount: Number.isFinite(retryCount) ? retryCount : undefined, tokenHash: typeof value.tokenHash === 'string' ? value.tokenHash : undefined, + // Preserve the dead-token discriminators across save/load. Without these, + // a retry-exhausted transient (permanent=false, 24h backoff) would lose its + // flag on reload and the 24h-delay heuristic would wrongly re-classify it + // permanent → false "needs re-login" nag. + status: Number.isFinite(status) ? status : undefined, + permanent: + typeof value.permanent === 'boolean' ? value.permanent : undefined, } } @@ -770,6 +794,17 @@ async function saveAccountStateUnlocked( delete next.accounts[id] } } + } else { + // Full save: drop any per-account state whose id is no longer present in + // storage.accounts. The scoped path above only prunes ids it was asked to + // save; on a removal the storage is saved with scope.accounts === true + // (ids === null), so without this branch the removed account's runtime + // state (quota/lastRefreshError/access/refresh/expires) would be orphaned + // in the state file and later merged onto a re-added same-id account. + const present = new Set(storage.accounts.map((account) => account.id)) + for (const id of Object.keys(next.accounts)) { + if (!present.has(id)) delete next.accounts[id] + } } } @@ -1253,13 +1288,63 @@ export function buildRefreshOperationError(input: { } else { delay = NON_TRANSIENT_REFRESH_RETRY_DELAY_MS } + const statusFromError = (input.error as { status?: unknown }).status + const status = + typeof statusFromError === 'number' && Number.isFinite(statusFromError) + ? statusFromError + : undefined + const message = formatErrorMessage(input.error) + // A token is permanently dead ONLY on 400 invalid_grant. The OAuth spec allows + // other 400s (invalid_client / invalid_request / unsupported_grant_type) that + // re-login does NOT fix — those, like a retry-exhausted / network / 429 / 5xx + // error, get a long backoff but must stay permanent=false so they are not + // falsely flagged "needs re-login". ClaudeOAuthRefreshError carries the raw + // OAuth body, and its message embeds it (`...: 400 —
`), so check both. + const body = + typeof (input.error as { body?: unknown }).body === 'string' + ? (input.error as { body: string }).body + : '' + const isInvalidGrant = + body.includes('invalid_grant') || message.includes('invalid_grant') return { - message: formatErrorMessage(input.error), + message, checkedAt: input.now, nextRetryAt: input.now + delay, retryCount, tokenHash, + status, + permanent: status === 400 && isInvalidGrant, + } +} + +/** + * True when a refresh error means the token is permanently dead and the account + * needs a re-login (vs a transient failure that recovers). + * + * Precedence: + * 1. the explicit `permanent` flag (set at construction from 400 invalid_grant) + * — the authoritative signal; correctly classifies a retry-exhausted/network + * error (long backoff, but NOT dead) as non-permanent; + * 2. else the captured HTTP `status` — 400 (for errors built before `permanent` + * existed but after `status`); + * 3. else the legacy 24h-delay heuristic — back-compat ONLY for errors persisted + * before either field existed (e.g. an operator's already-dead token: no + * status, ~24h backoff). It still flags those until the next refresh restamps + * the error with the explicit field. + */ +export function isPermanentRefreshError( + error: AccountOperationError | undefined, +): boolean { + if (!error) return false + if (typeof error.permanent === 'boolean') return error.permanent + if (typeof error.status === 'number') return error.status === 400 + if (typeof error.nextRetryAt === 'number') { + return ( + error.nextRetryAt - error.checkedAt >= + NON_TRANSIENT_REFRESH_RETRY_DELAY_MS + ) } + return false } export function refreshBackoffActive( diff --git a/packages/core/src/commands/account.ts b/packages/core/src/commands/account.ts index 26f83ad..44abc19 100644 --- a/packages/core/src/commands/account.ts +++ b/packages/core/src/commands/account.ts @@ -17,7 +17,7 @@ export type AccountCommandAction = authHeader?: 'authorization-bearer' | 'x-api-key' } | { type: 'add-oauth-start' } - | { type: 'add-oauth-finish'; code: string } + | { type: 'add-oauth-finish'; code: string; label?: string } | { type: 'usage' } export function parseAccountCommandAction( @@ -89,8 +89,22 @@ export function parseAccountCommandAction( if (action === 'add-oauth-start') return { type: 'add-oauth-start' } - if (action === 'add-oauth-finish' && rest) - return { type: 'add-oauth-finish', code: rest } + if (action === 'add-oauth-finish' && rest) { + let remaining = rest + + // Parse --label flag (mirrors add-apikey). The OAuth code is opaque (may + // contain a #state segment) so the label is collected via the flag, never + // positionally. + let label: string | undefined + const labelMatch = remaining.match(/--label\s+(.+)/) + if (labelMatch) { + label = labelMatch[1]?.trim() || undefined + remaining = remaining.replace(labelMatch[0], '').trim() + } + + if (!remaining) return { type: 'usage' } + return { type: 'add-oauth-finish', code: remaining, label } + } return { type: 'usage' } } diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index ab9b641..98ce7f9 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -61,6 +61,7 @@ import { isFastModeSupportedModel, isKillswitchEnabled, isOAuthAccount, + isPermanentRefreshError, isValidApiBaseURL, KILLSWITCH_COMMAND_NAME, killswitchPassesPolicy, @@ -661,6 +662,18 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { ? (quotaManager.getFallback(account.id, account.access)?.quota ?? null) : null, + // A fallback with a permanently-dead refresh token (400 invalid_grant) + // is dropped by getUsableFallbackAccounts and silently degrades to + // main — surface it as "needs re-login". Only flag truly-dead tokens + // whose backoff is still active, not transient (429/5xx) backoff. + needsReauth: + account.lastRefreshError != null && + refreshBackoffActive( + account.lastRefreshError, + account.refresh, + Date.now(), + ) && + isPermanentRefreshError(account.lastRefreshError), enabled: account.enabled !== false, })), activeId: options.activeId, @@ -1117,9 +1130,13 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { } const now = Date.now() + // OAuth accounts have no natural key, so the id stays a UUID even when a + // label is given (label collisions must not collide ids). The label is + // optional — a blank one keeps the UUID-name fallback in the UI. const account: OAuthAccount = { id: randomUUID(), type: 'oauth' as const, + label: action.label || undefined, access: result.access, refresh: result.refresh, expires: result.expires, diff --git a/packages/opencode/src/sidebar-state.ts b/packages/opencode/src/sidebar-state.ts index 9be9975..c4530e9 100644 --- a/packages/opencode/src/sidebar-state.ts +++ b/packages/opencode/src/sidebar-state.ts @@ -14,6 +14,9 @@ export interface SidebarAccountState { label: string | undefined quota: AccountQuota | null enabled: boolean + // True when the account's refresh token is permanently dead (400 + // invalid_grant) and it needs a re-login — distinct from a transient backoff. + needsReauth: boolean } export interface SidebarState { @@ -88,6 +91,8 @@ export function normalizeSidebarState(raw: unknown): SidebarState { label: typeof entry.label === 'string' ? entry.label : undefined, quota: isRecord(entry.quota) ? (entry.quota as AccountQuota) : null, enabled: typeof entry.enabled === 'boolean' ? entry.enabled : false, + needsReauth: + typeof entry.needsReauth === 'boolean' ? entry.needsReauth : false, })) : [] diff --git a/packages/opencode/src/tests/account-command.test.ts b/packages/opencode/src/tests/account-command.test.ts index 814f6ab..8e4e951 100644 --- a/packages/opencode/src/tests/account-command.test.ts +++ b/packages/opencode/src/tests/account-command.test.ts @@ -129,6 +129,33 @@ describe('parseAccountCommandAction', () => { test('garbage returns usage', () => { expect(parseAccountCommandAction('garbage')).toEqual({ type: 'usage' }) }) + + test('add-oauth-finish with code only (no label)', () => { + expect(parseAccountCommandAction('add-oauth-finish abc123')).toEqual({ + type: 'add-oauth-finish', + code: 'abc123', + }) + }) + + test('add-oauth-finish with --label', () => { + expect( + parseAccountCommandAction('add-oauth-finish abc123 --label work'), + ).toEqual({ + type: 'add-oauth-finish', + code: 'abc123', + label: 'work', + }) + }) + + test('add-oauth-finish --label with multi-word label', () => { + expect( + parseAccountCommandAction('add-oauth-finish abc123 --label my work acct'), + ).toEqual({ + type: 'add-oauth-finish', + code: 'abc123', + label: 'my work acct', + }) + }) }) // --------------------------------------------------------------------------- diff --git a/packages/opencode/src/tests/accounts.test.ts b/packages/opencode/src/tests/accounts.test.ts index 2389a0f..aca923a 100644 --- a/packages/opencode/src/tests/accounts.test.ts +++ b/packages/opencode/src/tests/accounts.test.ts @@ -27,6 +27,7 @@ import { isCacheKeepSubagentsEnabled, isCostZeroingEnabled, isFastModePersistentlyEnabled, + isPermanentRefreshError, type KillswitchThresholds, killswitchPassesPolicy, loadAccounts, @@ -1841,6 +1842,235 @@ describe('buildRefreshOperationError', () => { }) expect(result.nextRetryAt).toBe(1000000 + 5 * 60_000) }) + + test('captures the error status on the operation error', () => { + const error = new ClaudeOAuthRefreshError(400, 'invalid_grant') + const result = buildRefreshOperationError({ + error, + now: 1000000, + refreshToken: 'test-token', + }) + expect(result.status).toBe(400) + }) + + test('captures duck-typed status when no ClaudeOAuthRefreshError', () => { + const result = buildRefreshOperationError({ + error: { status: 429 }, + now: 1000000, + refreshToken: 'test-token', + }) + expect(result.status).toBe(429) + }) + + test('sets permanent true only for 400 invalid_grant', () => { + const dead = buildRefreshOperationError({ + error: new ClaudeOAuthRefreshError(400, 'invalid_grant'), + now: 1000000, + refreshToken: 't', + }) + expect(dead.permanent).toBe(true) + }) + + test('sets permanent false for retry-exhausted transient (no status)', () => { + const exhausted = buildRefreshOperationError({ + error: new Error('Token refresh exhausted all retries'), + now: 1000000, + refreshToken: 't', + }) + expect(exhausted.permanent).toBe(false) + }) + + test('sets permanent false for a 429', () => { + const rateLimited = buildRefreshOperationError({ + error: new ClaudeOAuthRefreshError(429, 'rate limited'), + now: 1000000, + refreshToken: 't', + }) + expect(rateLimited.permanent).toBe(false) + }) + + test('sets permanent false for a 400 that is NOT invalid_grant', () => { + // The OAuth spec allows 400 invalid_client / invalid_request / + // unsupported_grant_type — none of which re-login fixes. Only invalid_grant + // means the refresh token itself is dead. + const invalidClient = buildRefreshOperationError({ + error: new ClaudeOAuthRefreshError(400, '{"error":"invalid_client"}'), + now: 1000000, + refreshToken: 't', + }) + expect(invalidClient.status).toBe(400) + expect(invalidClient.permanent).toBe(false) + // Explicit false must short-circuit isPermanentRefreshError before the + // legacy status===400 fallback can wrongly flag it. + expect(isPermanentRefreshError(invalidClient)).toBe(false) + }) + + test('sets permanent true for a 400 invalid_grant from the body JSON', () => { + const dead = buildRefreshOperationError({ + error: new ClaudeOAuthRefreshError( + 400, + '{"error":"invalid_grant","error_description":"Refresh token expired"}', + ), + now: 1000000, + refreshToken: 't', + }) + expect(dead.permanent).toBe(true) + expect(isPermanentRefreshError(dead)).toBe(true) + }) +}) + +describe('isPermanentRefreshError', () => { + test('400 (invalid_grant) → permanent (dead token, needs re-login)', () => { + const error = buildRefreshOperationError({ + error: new ClaudeOAuthRefreshError(400, 'invalid_grant'), + now: 1000000, + refreshToken: 't', + }) + expect(isPermanentRefreshError(error)).toBe(true) + }) + + test('429 (rate limited) → NOT permanent (transient, recovers)', () => { + const error = buildRefreshOperationError({ + error: new ClaudeOAuthRefreshError(429, 'rate limited'), + now: 1000000, + refreshToken: 't', + }) + expect(isPermanentRefreshError(error)).toBe(false) + }) + + test('500 (server error) → NOT permanent (transient)', () => { + const error = buildRefreshOperationError({ + error: { status: 500 }, + now: 1000000, + refreshToken: 't', + }) + expect(isPermanentRefreshError(error)).toBe(false) + }) + + test('legacy error without status but 24h delay → permanent (heuristic)', () => { + // Mirrors a dead token persisted before the status field existed: a + // non-transient error gets NON_TRANSIENT_REFRESH_RETRY_DELAY_MS (24h). + const checkedAt = 1000000 + const error = { + message: 'invalid_grant', + checkedAt, + nextRetryAt: checkedAt + 24 * 60 * 60_000, + } + expect(isPermanentRefreshError(error)).toBe(true) + }) + + test('legacy error without status, short delay → NOT permanent', () => { + const checkedAt = 1000000 + const error = { + message: 'rate limited', + checkedAt, + nextRetryAt: checkedAt + 5 * 60_000, + } + expect(isPermanentRefreshError(error)).toBe(false) + }) + + test('retry-exhausted transient error → NOT permanent (no false re-login nag)', () => { + // A network glitch that exhausts all retries throws a plain Error with NO + // status. isTransientRefreshError classifies it non-transient (no status, + // message is not "fetch failed"), so it gets the 24h backoff delay — but it + // is NOT a dead token. Built through buildRefreshOperationError, the explicit + // `permanent` flag must be false so it is not nagged for re-login. + const error = buildRefreshOperationError({ + error: new Error('Token refresh exhausted all retries'), + now: 1000000, + refreshToken: 't', + }) + // Sanity: it really did get the 24h non-transient delay (so the legacy + // heuristic alone would have wrongly flagged it permanent). + expect(error.nextRetryAt).toBe(1000000 + 24 * 60 * 60_000) + expect(isPermanentRefreshError(error)).toBe(false) + }) +}) + +describe('isPermanentRefreshError across save/load round-trip', () => { + test('retry-exhausted transient survives round-trip as NON-permanent', async () => { + // The classification only matters once persisted. A retry-exhausted error + // is permanent=false in memory but gets a 24h NON_TRANSIENT backoff — if the + // status/permanent fields are dropped on load, the 24h-delay heuristic wrongly + // re-classifies it permanent and the sidebar falsely nags re-login. + const error = buildRefreshOperationError({ + error: new Error('Token refresh exhausted all retries'), + now: Date.now(), + refreshToken: 'rt-exhausted', + }) + expect(error.permanent).toBe(false) + expect(isPermanentRefreshError(error)).toBe(false) + + const storage = baseStorage() + storage.accounts.push({ + id: 'exhausted', + type: 'oauth', + refresh: 'rt-exhausted', + lastRefreshError: error, + }) + await saveAccounts(storage, accountPath) + + const loaded = await loadAccounts(accountPath) + const reloaded = expectOAuthAccount( + loaded!.accounts.find((a) => a.id === 'exhausted'), + ) + expect(reloaded.lastRefreshError?.permanent).toBe(false) + expect(isPermanentRefreshError(reloaded.lastRefreshError)).toBe(false) + }) + + test('400 invalid_grant survives round-trip as permanent', async () => { + const error = buildRefreshOperationError({ + error: new ClaudeOAuthRefreshError(400, 'invalid_grant'), + now: Date.now(), + refreshToken: 'rt-dead', + }) + expect(error.permanent).toBe(true) + + const storage = baseStorage() + storage.accounts.push({ + id: 'dead', + type: 'oauth', + refresh: 'rt-dead', + lastRefreshError: error, + }) + await saveAccounts(storage, accountPath) + + const loaded = await loadAccounts(accountPath) + const reloaded = expectOAuthAccount( + loaded!.accounts.find((a) => a.id === 'dead'), + ) + expect(reloaded.lastRefreshError?.status).toBe(400) + expect(reloaded.lastRefreshError?.permanent).toBe(true) + expect(isPermanentRefreshError(reloaded.lastRefreshError)).toBe(true) + }) + + test('400 non-invalid_grant survives round-trip as NON-permanent', async () => { + // A 400 invalid_client (misconfig) must NOT nag re-login — and the explicit + // permanent=false must survive load so the status===400 fallback never fires. + const error = buildRefreshOperationError({ + error: new ClaudeOAuthRefreshError(400, '{"error":"invalid_client"}'), + now: Date.now(), + refreshToken: 'rt-misconfig', + }) + expect(error.permanent).toBe(false) + + const storage = baseStorage() + storage.accounts.push({ + id: 'misconfig', + type: 'oauth', + refresh: 'rt-misconfig', + lastRefreshError: error, + }) + await saveAccounts(storage, accountPath) + + const loaded = await loadAccounts(accountPath) + const reloaded = expectOAuthAccount( + loaded!.accounts.find((a) => a.id === 'misconfig'), + ) + expect(reloaded.lastRefreshError?.status).toBe(400) + expect(reloaded.lastRefreshError?.permanent).toBe(false) + expect(isPermanentRefreshError(reloaded.lastRefreshError)).toBe(false) + }) }) // --------------------------------------------------------------------------- @@ -2660,6 +2890,59 @@ describe('removeAccountPersistent', () => { false, ) }) + + test('prunes the orphaned per-account state on removal (full-save path)', async () => { + const storage = baseStorage() + storage.accounts.push({ + id: 'doomed', + type: 'oauth', + access: 'access-token', + refresh: 'refresh-token', + expires: Date.now() + 3600_000, + lastRefreshError: { + message: 'boom', + checkedAt: Date.now(), + }, + }) + await saveAccounts(storage, accountPath) + + // Sanity: the state file holds the per-account block before removal. + const statePath = getAccountStatePath(accountPath) + const before = JSON.parse(await readFile(statePath, 'utf8')) + expect(before.accounts?.doomed).toBeDefined() + + expect(await removeAccountPersistent('doomed', accountPath)).toBe(true) + + const after = JSON.parse(await readFile(statePath, 'utf8')) + expect(after.accounts?.doomed).toBeUndefined() + }) + + test('removing one fallback leaves the other fallback state intact', async () => { + const storage = baseStorage() + storage.accounts.push({ + id: 'keep', + type: 'oauth', + access: 'keep-access', + refresh: 'keep-refresh', + expires: Date.now() + 3600_000, + }) + storage.accounts.push({ + id: 'drop', + type: 'oauth', + access: 'drop-access', + refresh: 'drop-refresh', + expires: Date.now() + 3600_000, + }) + await saveAccounts(storage, accountPath) + + expect(await removeAccountPersistent('drop', accountPath)).toBe(true) + + const statePath = getAccountStatePath(accountPath) + const after = JSON.parse(await readFile(statePath, 'utf8')) + expect(after.accounts?.drop).toBeUndefined() + expect(after.accounts?.keep).toBeDefined() + expect(after.accounts?.keep?.refresh).toBe('keep-refresh') + }) }) describe('reorderAccountsPersistent', () => { diff --git a/packages/opencode/src/tests/add-account-flows.test.ts b/packages/opencode/src/tests/add-account-flows.test.ts index cd3a059..aa097ca 100644 --- a/packages/opencode/src/tests/add-account-flows.test.ts +++ b/packages/opencode/src/tests/add-account-flows.test.ts @@ -428,6 +428,102 @@ describe('add-oauth error paths', () => { }) }) +// --------------------------------------------------------------------------- +// add-oauth — label threading (the fix: prompt for a label, default UUID name) +// --------------------------------------------------------------------------- +describe('add-oauth label threading', () => { + async function getPluginWithClient() { + const client = createMockClient() + const { AnthropicAuthPlugin } = await import('../index') + const plugin = (await AnthropicAuthPlugin({ + // @ts-expect-error: minimal mock for testing + client, + })) as any + return { plugin, client } + } + + function extractStateFromPromptCalls( + client: ReturnType