Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 86 additions & 1 deletion packages/core/src/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -365,13 +381,21 @@ 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,
nextRetryAt: Number.isFinite(nextRetryAt) ? nextRetryAt : undefined,
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,
}
}

Expand Down Expand Up @@ -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]
}
}
}

Expand Down Expand Up @@ -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 — <body>`), 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(
Expand Down
20 changes: 17 additions & 3 deletions packages/core/src/commands/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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' }
}
Expand Down
17 changes: 17 additions & 0 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
isFastModeSupportedModel,
isKillswitchEnabled,
isOAuthAccount,
isPermanentRefreshError,
isValidApiBaseURL,
KILLSWITCH_COMMAND_NAME,
killswitchPassesPolicy,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/src/sidebar-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
}))
: []

Expand Down
27 changes: 27 additions & 0 deletions packages/opencode/src/tests/account-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
})
})
})

// ---------------------------------------------------------------------------
Expand Down
Loading