From 4f2e012b2d3c76506e3d2101d51f72198f63b4e8 Mon Sep 17 00:00:00 2001 From: Christian Battaglia Date: Sun, 2 Aug 2026 10:55:08 -0400 Subject: [PATCH 1/8] fix: capture the OAuth token endpoint failure reason on refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refreshViaOAuth() logged only "HTTP " when a token refresh was rejected, discarding the endpoint's own error body. A persistent 401 (access token rejected, then refresh unable to recover) was therefore indistinguishable between an expired/revoked refresh token (invalid_grant), a client mismatch (invalid_client), and transient rate limiting — all surface to the user as the same opaque "run claude to re-authenticate". Parse the token endpoint's error body via a new extractOAuthError() helper and include the non-secret oauthError / oauthErrorDescription fields in the refresh_failed debug event. Handles both the OAuth shape ({error, error_description}) and Anthropic's nested API envelope ({error: {type, message}}); values are truncated and the logger still redacts anything JWT-shaped. No behavioral change to the refresh flow itself — diagnostics only. --- src/credentials.test.ts | 90 +++++++++++++++++++++++++++++++++++++++++ src/credentials.ts | 44 ++++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/src/credentials.test.ts b/src/credentials.test.ts index 5b7186e..b9f78cc 100644 --- a/src/credentials.test.ts +++ b/src/credentials.test.ts @@ -3,8 +3,11 @@ import assert from "node:assert/strict" import { refreshViaOAuth, parseOAuthResponse, + extractOAuthError, OAUTH_TOKEN_URL, } from "./credentials.ts" +import { Writable } from "node:stream" +import { closeLogger, initLogger } from "./logger.ts" import { chmodSync, mkdirSync, @@ -1712,6 +1715,93 @@ describe("refreshViaOAuth", () => { globalThis.fetch = originalFetch } }) + + it("logs the token endpoint's failure reason on a rejected refresh", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + error: "invalid_grant", + error_description: "Refresh token not found or invalid", + }), + { status: 400 }, + )) as typeof fetch + + const lines: string[] = [] + initLogger({ + stream: new Writable({ + write(chunk, _enc, cb) { + lines.push(chunk.toString()) + cb() + }, + }), + }) + + try { + assert.equal(await refreshViaOAuth("sk-ant-ort01-stale"), null) + const entry = lines + .map((l) => JSON.parse(l) as Record) + .find((e) => e.event === "refresh_failed") + assert.ok(entry, "expected a refresh_failed log line") + assert.equal(entry.error, "HTTP 400") + assert.equal(entry.oauthError, "invalid_grant") + assert.equal( + entry.oauthErrorDescription, + "Refresh token not found or invalid", + ) + } finally { + closeLogger() + globalThis.fetch = originalFetch + } + }) +}) + +describe("extractOAuthError", () => { + it("extracts the OAuth error and description", () => { + assert.deepEqual( + extractOAuthError( + JSON.stringify({ + error: "invalid_grant", + error_description: "Refresh token not found or invalid", + }), + ), + { + oauthError: "invalid_grant", + oauthErrorDescription: "Refresh token not found or invalid", + }, + ) + }) + + it("handles Anthropic's nested error envelope", () => { + assert.deepEqual( + extractOAuthError( + JSON.stringify({ + error: { type: "rate_limit_error", message: "Rate limited." }, + }), + ), + { + oauthError: "rate_limit_error", + oauthErrorDescription: "Rate limited.", + }, + ) + }) + + it("returns an empty object for non-JSON bodies", () => { + assert.deepEqual(extractOAuthError("gateway error"), {}) + }) + + it("returns an empty object when no error field is present", () => { + assert.deepEqual(extractOAuthError(JSON.stringify({ ok: true })), {}) + }) + + it("truncates overly long descriptions", () => { + const long = "x".repeat(1000) + const result = extractOAuthError( + JSON.stringify({ error: "server_error", error_description: long }), + ) + assert.equal(result.oauthError, "server_error") + assert.equal(result.oauthErrorDescription?.length, 500) + }) }) function makeAccount(expiresAt: number) { diff --git a/src/credentials.ts b/src/credentials.ts index 35ead1d..209d07f 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -197,6 +197,45 @@ export function parseOAuthResponse( } } +/** + * Extract the non-secret failure reason from an OAuth token-endpoint error + * body so a refresh failure is diagnosable from the debug log. Handles both the + * OAuth shape (`{ error, error_description }`) and Anthropic's API error + * envelope (`{ error: { type, message } }`). Values are truncated and never + * include tokens; the logger additionally redacts anything JWT-shaped. + */ +export function extractOAuthError(raw: string): { + oauthError?: string + oauthErrorDescription?: string +} { + let data: { + error?: unknown + // eslint-disable-next-line @typescript-eslint/naming-convention + error_description?: unknown + } + try { + data = JSON.parse(raw) + } catch { + return {} + } + + const out: { oauthError?: string; oauthErrorDescription?: string } = {} + if (typeof data.error === "string") { + out.oauthError = data.error.slice(0, 200) + } else if (data.error && typeof data.error === "object") { + const nested = data.error as { type?: unknown; message?: unknown } + if (typeof nested.type === "string") + out.oauthError = nested.type.slice(0, 200) + if (typeof nested.message === "string") { + out.oauthErrorDescription = nested.message.slice(0, 500) + } + } + if (typeof data.error_description === "string") { + out.oauthErrorDescription = data.error_description.slice(0, 500) + } + return out +} + const OAUTH_TIMEOUT_MS = 15_000 /** @@ -238,9 +277,14 @@ export async function refreshViaOAuth( }) if (!response.ok) { + // Capture the token endpoint's own failure reason (invalid_grant, + // invalid_client, rate_limit_error, ...) so a persistent 401 is + // diagnosable rather than an opaque "HTTP 400". + const detail = extractOAuthError(await response.text().catch(() => "")) log("refresh_failed", { source: "oauth", error: `HTTP ${response.status}`, + ...detail, }) return null } From 0dcf0f21d418c9e31de45272ec7aca17f4f2a661 Mon Sep 17 00:00:00 2001 From: Christian Battaglia Date: Mon, 3 Aug 2026 11:04:58 -0400 Subject: [PATCH 2/8] fix: treat a rate-limited OAuth refresh as transient, not terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A token that expires while OpenCode is closed must be refreshed on the next request. When the token endpoint rate-limits that refresh (HTTP 429 `rate_limit_error`) the plugin previously treated it as a hard failure: it gave up after ~6s, spawned the `claude` CLI (which hits the same rate-limited endpoint and also fails), and surfaced "credentials unavailable. Run `claude`" — even though the refresh token was still valid. N OpenCode instances refreshing at once turned one rate-limit into a storm. Classify refresh failures (new refresh-backoff module): - transient (429/5xx/network/`rate_limit_error`): the refresh token is still good. Apply a per-account cooldown with jitter, adopt a token a sibling instance or the CLI may have just written to the shared store, and do NOT spawn the CLI. Requests wait through the cooldown (bounded, abort-aware, OPENCODE_CLAUDE_AUTH_REFRESH_WAIT_MS) via getCredentialsWithBackoff and return a real 200 once it clears; only on exhaustion do they return a retryable 429 so OpenCode/AI-SDK retries instead of showing a hard error. - terminal (`invalid_grant`, ...): the refresh token is dead — keep the existing CLI-fallback / re-auth path. Diagnostics: refresh_transient, refresh_terminal, refresh_cooldown_skip, refresh_adopted_from_source, fetch_credentials_wait, fetch_credentials_transient_exhausted (all redacted). --- src/credentials.test.ts | 154 +++++++++++++++++++- src/credentials.ts | 273 ++++++++++++++++++++++++++++++++++-- src/index.test.ts | 1 + src/index.ts | 40 +++++- src/refresh-backoff.test.ts | 130 +++++++++++++++++ src/refresh-backoff.ts | 134 ++++++++++++++++++ 6 files changed, 711 insertions(+), 21 deletions(-) create mode 100644 src/refresh-backoff.test.ts create mode 100644 src/refresh-backoff.ts diff --git a/src/credentials.test.ts b/src/credentials.test.ts index b9f78cc..052eea1 100644 --- a/src/credentials.test.ts +++ b/src/credentials.test.ts @@ -55,6 +55,15 @@ async function loadCredentialsWithCountingKeychain( forceRefreshActiveAccount: ( refresh?: (refreshToken: string) => Promise, ) => Promise + getCredentialsWithBackoff: (opts?: { + maxWaitMs?: number + pollMs?: number + signal?: AbortSignal + now?: () => number + sleep?: (ms: number, signal?: AbortSignal) => Promise + rng?: () => number + }) => Promise + getActiveRefreshFailureKind: () => "transient" | "terminal" | null } keychainModule: { __getReadCount: () => number @@ -98,6 +107,11 @@ async function loadCredentialsWithCountingKeychain( await readFile(new URL("./http.ts", import.meta.url), "utf8"), "utf8", ) + await writeFile( + join(tempDir, "refresh-backoff.ts"), + await readFile(new URL("./refresh-backoff.ts", import.meta.url), "utf8"), + "utf8", + ) const rewritten = sourceCredentials .replace(/from\s+["']\.\/(\w+)\.js["']/g, 'from "./$1.ts"') .replace( @@ -1427,6 +1441,14 @@ describe("syncAuthJson file permissions", () => { await readFile(new URL("./http.ts", import.meta.url), "utf8"), "utf8", ) + await writeFile( + join(tempDir, "refresh-backoff.ts"), + await readFile( + new URL("./refresh-backoff.ts", import.meta.url), + "utf8", + ), + "utf8", + ) const rewritten = sourceCredentials.replace( /from\s+["']\.\/(\w+)\.js["']/g, 'from "./$1.ts"', @@ -1517,6 +1539,14 @@ export function buildAccountLabels(creds) { return creds.map((_, i) => \`Account await readFile(new URL("./http.ts", import.meta.url), "utf8"), "utf8", ) + await writeFile( + join(tempDir, "refresh-backoff.ts"), + await readFile( + new URL("./refresh-backoff.ts", import.meta.url), + "utf8", + ), + "utf8", + ) const rewritten = sourceCredentials.replace( /from\s+["']\.\/(\w+)\.js["']/g, 'from "./$1.ts"', @@ -1953,7 +1983,8 @@ describe("refreshIfNeeded CLI fallback scope", () => { const originalFetch = globalThis.fetch const originalNow = Date.now const now = 1_700_000_000_000 - Date.now = () => now + let clock = now + Date.now = () => clock let fetchCount = 0 globalThis.fetch = (async () => { @@ -1976,6 +2007,11 @@ describe("refreshIfNeeded CLI fallback scope", () => { ]) const afterFirstRound = fetchCount + // Advance past the post-transient refresh cooldown so the next round + // actually re-attempts (rather than being cooldown-skipped) — the point + // of the assertion is that the retry still collapses to one attempt. + clock += 61_000 + const second = await Promise.all([ credentialsModule.getCachedCredentials(), credentialsModule.getCachedCredentials(), @@ -2045,9 +2081,12 @@ describe("refreshIfNeeded CLI fallback scope", () => { const now = 1_700_000_000_000 Date.now = () => now - globalThis.fetch = (async () => { - throw new Error("network unreachable") - }) as typeof fetch + // A terminal failure (dead refresh token) is what routes to the CLI now; + // a transient rate-limit/network error deliberately does not spawn it. + globalThis.fetch = (async () => + new Response(JSON.stringify({ error: "invalid_grant" }), { + status: 400, + })) as typeof fetch try { const { credentialsModule, keychainModule, childProcessModule } = @@ -2753,3 +2792,110 @@ describe("parseOAuthResponse", () => { assert.equal(parseOAuthResponse("", currentRefresh, now), null) }) }) + +describe("getCredentialsWithBackoff (transient rate-limit resilience)", () => { + it("returns fresh credentials immediately without waiting", async () => { + const originalFetch = globalThis.fetch + const originalNow = Date.now + const now = 1_700_000_000_000 + Date.now = () => now + try { + const { credentialsModule } = await loadCredentialsWithCountingKeychain( + now + 10 * 60_000, + ) + credentialsModule.initAccounts([makeAccount(now + 10 * 60_000)]) + + let slept = 0 + const creds = await credentialsModule.getCredentialsWithBackoff({ + now: () => now, + sleep: async () => { + slept += 1 + }, + }) + + assert.ok(creds, "credentials returned immediately") + assert.equal(slept, 0, "no wait when credentials are already available") + } finally { + globalThis.fetch = originalFetch + Date.now = originalNow + } + }) + + it("returns null promptly on a terminal failure, without exhausting the wait", async () => { + const originalFetch = globalThis.fetch + const originalNow = Date.now + const now = 1_700_000_000_000 + Date.now = () => now + globalThis.fetch = (async () => + new Response(JSON.stringify({ error: "invalid_grant" }), { + status: 400, + })) as typeof fetch + try { + const { credentialsModule } = await loadCredentialsWithCountingKeychain( + now - 1_000, + ) + credentialsModule.initAccounts([makeAccount(now - 1_000)]) + + let slept = 0 + const creds = await credentialsModule.getCredentialsWithBackoff({ + maxWaitMs: 100_000, + now: () => now, + sleep: async () => { + slept += 1 + }, + }) + + assert.equal(creds, null) + assert.equal(credentialsModule.getActiveRefreshFailureKind(), "terminal") + assert.equal(slept, 0, "a dead refresh token is not waited out") + } finally { + globalThis.fetch = originalFetch + Date.now = originalNow + } + }) + + it("adopts a token a sibling instance/CLI writes to the store during the cooldown", async () => { + const originalFetch = globalThis.fetch + const originalNow = Date.now + const now = 1_700_000_000_000 + Date.now = () => now + // The token endpoint is rate-limiting us (transient). A retry-after beyond + // the fetchWithRetry cap makes it return at once rather than backing off, + // keeping the test fast. + globalThis.fetch = (async () => + new Response(JSON.stringify({ error: "rate_limit_error" }), { + status: 429, + headers: { "retry-after": "3600" }, + })) as typeof fetch + try { + const { credentialsModule, keychainModule } = + await loadCredentialsWithCountingKeychain(now - 1_000) + const target = makeAccount(now - 1_000) + credentialsModule.initAccounts([target]) + keychainModule.__setCredentials({ + accessToken: "existing-token", + refreshToken: "existing-refresh", + expiresAt: now - 1_000, + }) + + // First refresh is rate-limited -> sets a cooldown, no credentials. + assert.equal(await credentialsModule.refreshIfNeeded(target), null) + assert.equal(credentialsModule.getActiveRefreshFailureKind(), "transient") + + // A sibling OpenCode instance / the claude CLI rotates the shared store. + keychainModule.__setCredentials({ + accessToken: "sibling-rotated-token", + refreshToken: "sibling-rotated-refresh", + expiresAt: now + 8 * 60 * 60_000, + }) + + // While still in cooldown we must NOT hit the endpoint again — we adopt + // the sibling's fresh token from the store instead. + const adopted = await credentialsModule.refreshIfNeeded(target) + assert.equal(adopted?.accessToken, "sibling-rotated-token") + } finally { + globalThis.fetch = originalFetch + Date.now = originalNow + } + }) +}) diff --git a/src/credentials.ts b/src/credentials.ts index 209d07f..0e2ed54 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -19,6 +19,16 @@ import { import { resetExcludedBetas } from "./betas.ts" import { fetchWithRetry } from "./http.ts" import { log } from "./logger.ts" +import { + classifyRefreshFailure, + clearRefreshOutcome, + getRefreshCooldownUntil, + getRefreshFailureKind, + isRefreshCooldownActive, + noteRefreshTerminal, + noteRefreshTransient, + type RefreshFailureKind, +} from "./refresh-backoff.ts" export type { ClaudeAccount } from "./keychain.ts" export type { ClaudeCredentials } from "./keychain.ts" @@ -250,10 +260,36 @@ const OAUTH_TIMEOUT_MS = 15_000 * non-zero with empty stdout and silently fell through to the claude CLI. * Node 18+ and Bun both expose a global fetch, so no subprocess is needed. */ -export async function refreshViaOAuth( +/** + * Classified result of an OAuth refresh. A `transient` outcome (429/5xx/network + * /`rate_limit_error`) means the refresh token is still good and the caller + * should back off and retry rather than surface a hard error; a `terminal` + * outcome (`invalid_grant`, ...) means the refresh token is dead. + */ +export type RefreshOutcome = + | { kind: "ok"; creds: ClaudeCredentials } + | { + kind: "transient" + status: number + oauthError?: string + retryAfterMs?: number + } + | { kind: "terminal"; status: number; oauthError?: string } + +function parseRetryAfterMs(headerValue: string | null): number | undefined { + if (!headerValue) return undefined + const seconds = Number.parseInt(headerValue, 10) + return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : undefined +} + +/** + * Exchange a refresh token for fresh credentials and classify the result. + * See {@link RefreshOutcome}. Uses the runtime's own fetch (no subprocess). + */ +export async function refreshViaOAuthDetailed( refreshToken: string, timeoutMs = OAUTH_TIMEOUT_MS, -): Promise { +): Promise { const body = new URLSearchParams({ grant_type: "refresh_token", client_id: OAUTH_CLIENT_ID, @@ -265,10 +301,6 @@ export async function refreshViaOAuth( try { log("refresh_started", { source: "oauth" }) - // The token endpoint rate-limits valid refresh requests, and several - // OpenCode instances refreshing near expiry cluster their calls, so a - // 429 here is transient rather than terminal. The shared helper caps - // its own backoff, and the abort signal bounds the whole sequence. const response = await fetchWithRetry(OAUTH_TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, @@ -281,36 +313,66 @@ export async function refreshViaOAuth( // invalid_client, rate_limit_error, ...) so a persistent 401 is // diagnosable rather than an opaque "HTTP 400". const detail = extractOAuthError(await response.text().catch(() => "")) + const kind = classifyRefreshFailure(response.status, detail.oauthError) + const retryAfterMs = parseRetryAfterMs( + response.headers.get("retry-after"), + ) log("refresh_failed", { source: "oauth", error: `HTTP ${response.status}`, + kind, ...detail, }) - return null + return kind === "terminal" + ? { kind, status: response.status, oauthError: detail.oauthError } + : { + kind, + status: response.status, + oauthError: detail.oauthError, + retryAfterMs, + } } const creds = parseOAuthResponse(await response.text(), refreshToken) if (!creds) { + // A 200 we cannot parse is an endpoint hiccup, not a dead token — treat + // it as transient so a retry can recover. log("refresh_failed", { source: "oauth", error: "no access_token in response", + kind: "transient", }) - return null + return { kind: "transient", status: response.status } } log("refresh_success", { source: "oauth" }) - return creds + return { kind: "ok", creds } } catch (err) { + // Network error / abort: transient by nature. log("refresh_failed", { source: "oauth", error: err instanceof Error ? err.message : String(err), + kind: "transient", }) - return null + return { kind: "transient", status: 0 } } finally { clearTimeout(timer) } } +/** + * Backward-compatible wrapper: returns credentials on success, else null. + * Prefer {@link refreshViaOAuthDetailed} when the transient/terminal + * distinction matters (cooldown, CLI-fallback gating). + */ +export async function refreshViaOAuth( + refreshToken: string, + timeoutMs = OAUTH_TIMEOUT_MS, +): Promise { + const outcome = await refreshViaOAuthDetailed(refreshToken, timeoutMs) + return outcome.kind === "ok" ? outcome.creds : null +} + function refreshViaCli(configDir?: string, requireConfigDir = false): boolean { if (requireConfigDir && !configDir) { log("refresh_cli_skipped", { @@ -421,6 +483,24 @@ export async function refreshIfNeeded( const creds = target.credentials if (creds.expiresAt > Date.now() + thresholdMs) return creds + // If a recent refresh was rate-limited, don't re-hit the endpoint until the + // cooldown clears — adopt a sibling instance's / the CLI's fresh token if one + // has appeared, else defer. This is what stops N OpenCode instances from + // turning a single transient 429 into a sustained storm. Borrowed accounts + // are exempt: their recovery (refreshBorrowedAccount) is a distinct path. + if ( + !borrowedCredentialAccounts.has(target) && + isRefreshCooldownActive(target.source) + ) { + const adopted = adoptFreshFromSource(target, creds.accessToken) + if (adopted) return adopted + log("refresh_cooldown_skip", { + source: target.source, + until: getRefreshCooldownUntil(target.source), + }) + return null + } + // The proactive sync timer calls this directly while the request path // arrives via getCachedCredentials(). A rotation invalidates the refresh // token it was issued against, so two concurrent refreshes would leave @@ -440,6 +520,35 @@ export async function refreshIfNeeded( } } +/** + * Re-read the account's own source and adopt a token another OpenCode instance + * or the `claude` CLI has just written. Returns the adopted credentials when + * the store now holds a distinct, still-valid token, else null. + */ +function adoptFreshFromSource( + target: ClaudeAccount, + rejectedAccessToken?: string, +): ClaudeCredentials | null { + let stored: ClaudeCredentials | null = null + try { + stored = refreshAccount(target.source, target.configDir) + } catch { + return null + } + if ( + stored && + stored.accessToken !== rejectedAccessToken && + stored.expiresAt > Date.now() + 60_000 + ) { + target.credentials = stored + borrowedCredentialAccounts.delete(target) + clearRefreshOutcome(target.source) + log("refresh_adopted_from_source", { source: target.source }) + return stored + } + return null +} + async function performRefresh( target: ClaudeAccount, creds: ClaudeCredentials, @@ -455,13 +564,18 @@ async function performRefresh( }) if (creds.refreshToken) { - const oauthCreds = await refreshViaOAuth(creds.refreshToken) - if (oauthCreds && oauthCreds.expiresAt > Date.now() + 60_000) { - target.credentials = oauthCreds + const outcome = await refreshViaOAuthDetailed(creds.refreshToken) + + if ( + outcome.kind === "ok" && + outcome.creds.expiresAt > Date.now() + 60_000 + ) { + clearRefreshOutcome(target.source) + target.credentials = outcome.creds if ( !writeBackCredentials( target.source, - oauthCreds, + outcome.creds, target.configDir, creds.accessToken, ) @@ -474,7 +588,48 @@ async function performRefresh( // the validated re-read — is tracked as a follow-up. log("refresh_writeback_failed", { source: target.source }) } - return oauthCreds + return outcome.creds + } + + if (outcome.kind === "transient") { + // A rate-limit / 5xx / network blip: the refresh token is still valid. + // Back off so we (and our sibling OpenCode instances) stop hammering the + // endpoint, adopt a token another instance/CLI may have just written, + // and — crucially — do NOT spawn the claude CLI, which hits the same + // rate-limited endpoint and only deepens the limit. + const cooldownMs = noteRefreshTransient(target.source, { + retryAfterMs: outcome.retryAfterMs, + }) + log("refresh_transient", { + source: target.source, + status: outcome.status, + oauthError: outcome.oauthError, + cooldownMs, + }) + const adopted = adoptFreshFromSource(target, creds.accessToken) + if (adopted) return adopted + // Keep serving still-usable credentials on the proactive path. + if (creds.expiresAt > Date.now() + CLI_FALLBACK_THRESHOLD_MS) return creds + // Borrow a sibling account's still-valid token rather than spawning the + // claude CLI, which hits the same rate-limited endpoint. + const borrowed = tryFallbackAccount(target.source) + if (borrowed) { + target.credentials = borrowed + borrowedCredentialAccounts.add(target) + return borrowed + } + return null + } + + if (outcome.kind === "terminal") { + // The refresh token itself is dead (invalid_grant, ...). Fall through to + // the CLI fallback / borrowed-account recovery below. + noteRefreshTerminal(target.source) + log("refresh_terminal", { + source: target.source, + status: outcome.status, + oauthError: outcome.oauthError, + }) } } @@ -815,6 +970,94 @@ export async function getCachedCredentials(): Promise return fresh } +/** Max time a single request will wait through a transient refresh rate-limit. */ +const REFRESH_WAIT_MS = (() => { + const raw = process.env.OPENCODE_CLAUDE_AUTH_REFRESH_WAIT_MS + const parsed = raw ? Number.parseInt(raw, 10) : NaN + return Number.isFinite(parsed) && parsed >= 0 ? parsed : 45_000 +})() + +const REFRESH_POLL_MS = 2_500 + +function sleepAbortable(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal?.aborted) { + resolve() + return + } + const done = () => { + clearTimeout(timer) + signal?.removeEventListener("abort", done) + resolve() + } + const timer = setTimeout(done, ms) + signal?.addEventListener("abort", done, { once: true }) + }) +} + +export interface CredentialWaitOptions { + maxWaitMs?: number + pollMs?: number + signal?: AbortSignal + now?: () => number + sleep?: (ms: number, signal?: AbortSignal) => Promise + rng?: () => number +} + +/** + * Resolve credentials, waiting through a transient refresh rate-limit rather + * than failing hard. Returns as soon as a token is available — ours refreshed + * once the cooldown clears, or a sibling OpenCode instance / the `claude` CLI + * wrote a fresh one to the shared store. Returns null promptly on a terminal + * failure (dead refresh token) or when the wait budget is exhausted, so the + * caller can decide between a retryable response and a hard error. + */ +export async function getCredentialsWithBackoff( + opts: CredentialWaitOptions = {}, +): Promise { + const first = await getCachedCredentials() + if (first) return first + + const source = getActiveAccount()?.source + // A dead refresh token will not fix itself by waiting. + if (source && getRefreshFailureKind(source) === "terminal") return null + + const now = opts.now ?? Date.now + const sleep = opts.sleep ?? sleepAbortable + const rng = opts.rng ?? Math.random + const maxWaitMs = opts.maxWaitMs ?? REFRESH_WAIT_MS + const pollMs = opts.pollMs ?? REFRESH_POLL_MS + const deadline = now() + maxWaitMs + + log("fetch_credentials_wait", { source: source ?? null, maxWaitMs }) + + while (now() < deadline) { + if (opts.signal?.aborted) return null + // Jittered poll so sibling instances desynchronize their re-reads. + await sleep(Math.round(pollMs * (0.5 + rng() * 0.5)), opts.signal) + if (opts.signal?.aborted) return null + const creds = await getCachedCredentials() + if (creds) return creds + if (source && getRefreshFailureKind(source) === "terminal") return null + } + return null +} + +/** + * Whether the active account's most recent refresh failure was transient + * (rate-limited/retryable) or terminal (dead refresh token), for callers + * deciding between a retryable response and a hard "re-authenticate" error. + * An active cooldown implies a transient failure. + */ +export function getActiveRefreshFailureKind(): RefreshFailureKind | null { + const source = getActiveAccount()?.source + if (!source) return null + const kind = getRefreshFailureKind(source) + if (kind === "transient" || isRefreshCooldownActive(source)) + return "transient" + return kind +} + export function reloadCredentialsFromSource(): ClaudeCredentials | null { const account = getActiveAccount() if (!account) return null diff --git a/src/index.test.ts b/src/index.test.ts index 9c356a3..4bf6512 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -125,6 +125,7 @@ const SOURCE_FILES = [ "signing.ts", "transforms.ts", "credentials.ts", + "refresh-backoff.ts", "logger.ts", "http.ts", ] as const diff --git a/src/index.ts b/src/index.ts index fff9c6c..3ad8757 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,8 @@ import { } from "./transforms.ts" import { getCachedCredentials, + getCredentialsWithBackoff, + getActiveRefreshFailureKind, reloadCredentialsFromSource, forceRefreshActiveAccount, getActiveAccount, @@ -306,15 +308,49 @@ const plugin: Plugin = async () => { apiKey: "", baseURL: "https://api.anthropic.com/v1", async fetch(input: RequestInfo | URL, init?: RequestInit) { - const latest = await getCachedCredentials() + const requestInit = init ?? {} + let latest = await getCachedCredentials() + if (!latest) { + // A transient refresh rate-limit must not surface as a hard error. + // Wait (bounded, abort-aware) for our cooldown to clear or for a + // sibling OpenCode instance / the claude CLI to write a fresh + // token to the shared store. + latest = await getCredentialsWithBackoff({ + signal: requestInit.signal ?? undefined, + }) + } if (!latest) { + if (getActiveRefreshFailureKind() === "transient") { + // Retryable: let OpenCode/the AI SDK back off and retry rather + // than telling the user to re-authenticate for a passing + // rate-limit that the refresh token would otherwise survive. + log("fetch_credentials_transient_exhausted", { + modelId: "unknown", + }) + return new Response( + JSON.stringify({ + type: "error", + error: { + type: "overloaded_error", + message: + "Claude token refresh is rate-limited; retry shortly.", + }, + }), + { + status: 429, + headers: { + "content-type": "application/json", + "retry-after": "5", + }, + }, + ) + } log("fetch_no_credentials", { modelId: "unknown" }) throw new Error( "Claude Code credentials are unavailable or expired. Run `claude` to refresh them.", ) } - const requestInit = init ?? {} const bodyStr = typeof requestInit.body === "string" ? requestInit.body diff --git a/src/refresh-backoff.test.ts b/src/refresh-backoff.test.ts new file mode 100644 index 0000000..d3b1f75 --- /dev/null +++ b/src/refresh-backoff.test.ts @@ -0,0 +1,130 @@ +import { describe, it, beforeEach } from "node:test" +import assert from "node:assert/strict" +import { + classifyRefreshFailure, + computeBackoffMs, + noteRefreshTransient, + noteRefreshTerminal, + clearRefreshOutcome, + isRefreshCooldownActive, + getRefreshCooldownUntil, + getRefreshFailureKind, + resetRefreshBackoffState, + BASE_COOLDOWN_MS, + MAX_COOLDOWN_MS, +} from "./refresh-backoff.ts" + +const SRC = "Claude Code-credentials" + +describe("refresh-backoff", () => { + beforeEach(() => resetRefreshBackoffState()) + + describe("classifyRefreshFailure", () => { + it("treats rate limiting as transient", () => { + assert.equal(classifyRefreshFailure(429, "rate_limit_error"), "transient") + assert.equal(classifyRefreshFailure(429), "transient") + }) + + it("treats server errors and network failures as transient", () => { + assert.equal(classifyRefreshFailure(500), "transient") + assert.equal(classifyRefreshFailure(503), "transient") + assert.equal(classifyRefreshFailure(0), "transient") // network / no response + }) + + it("treats a revoked/invalid refresh token as terminal", () => { + assert.equal(classifyRefreshFailure(400, "invalid_grant"), "terminal") + assert.equal(classifyRefreshFailure(401, "invalid_client"), "terminal") + assert.equal( + classifyRefreshFailure(400, "unauthorized_client"), + "terminal", + ) + assert.equal( + classifyRefreshFailure(400, "unsupported_grant_type"), + "terminal", + ) + }) + + it("defaults unknown 4xx to transient (never a spurious hard error)", () => { + assert.equal(classifyRefreshFailure(400, "something_new"), "transient") + assert.equal(classifyRefreshFailure(418), "transient") + }) + }) + + describe("computeBackoffMs", () => { + it("honors an explicit retry-after over the exponential schedule", () => { + assert.equal( + computeBackoffMs(1, { retryAfterMs: 12_345, rng: () => 0 }), + 12_345, + ) + }) + + it("grows exponentially with consecutive failures and is capped", () => { + const a = computeBackoffMs(1, { rng: () => 0 }) + const b = computeBackoffMs(2, { rng: () => 0 }) + const c = computeBackoffMs(99, { rng: () => 0 }) + assert.ok(b > a, "second failure backs off longer") + assert.ok(c <= MAX_COOLDOWN_MS, "backoff is capped") + assert.ok(a >= BASE_COOLDOWN_MS / 2, "first backoff near the base floor") + }) + + it("applies jitter within the [50%, 100%] band of the scheduled delay", () => { + const low = computeBackoffMs(1, { rng: () => 0 }) + const high = computeBackoffMs(1, { rng: () => 1 }) + assert.ok(high > low, "rng=1 yields a larger delay than rng=0") + assert.ok(low >= BASE_COOLDOWN_MS * 0.5) + assert.ok(high <= BASE_COOLDOWN_MS) + }) + }) + + describe("cooldown lifecycle", () => { + it("activates a cooldown on a transient failure and reports the failure kind", () => { + const now = 1_000_000 + const ms = noteRefreshTransient(SRC, { now, rng: () => 0 }) + assert.ok(ms > 0) + assert.equal(isRefreshCooldownActive(SRC, now + 1), true) + assert.equal(isRefreshCooldownActive(SRC, now + ms + 1), false) + assert.equal(getRefreshCooldownUntil(SRC), now + ms) + assert.equal(getRefreshFailureKind(SRC), "transient") + }) + + it("escalates the cooldown across consecutive transient failures", () => { + const now = 1_000_000 + const first = noteRefreshTransient(SRC, { now, rng: () => 0 }) + const second = noteRefreshTransient(SRC, { now, rng: () => 0 }) + assert.ok(second > first, "consecutive transients back off further") + }) + + it("records a terminal failure without a cooldown", () => { + noteRefreshTerminal(SRC) + assert.equal(getRefreshFailureKind(SRC), "terminal") + assert.equal(isRefreshCooldownActive(SRC, Date.now()), false) + }) + + it("clears cooldown, consecutive count, and failure kind on success", () => { + const now = 1_000_000 + noteRefreshTransient(SRC, { now, rng: () => 0 }) + noteRefreshTransient(SRC, { now, rng: () => 0 }) + clearRefreshOutcome(SRC) + assert.equal(isRefreshCooldownActive(SRC, now + 1), false) + assert.equal(getRefreshFailureKind(SRC), null) + // consecutive count reset: the next transient starts from the base again + const afterReset = noteRefreshTransient(SRC, { now, rng: () => 0 }) + const firstEver = (() => { + resetRefreshBackoffState() + return noteRefreshTransient(SRC, { now, rng: () => 0 }) + })() + assert.equal(afterReset, firstEver) + }) + + it("honors a retry-after hint when setting the cooldown", () => { + const now = 1_000_000 + const ms = noteRefreshTransient(SRC, { + now, + retryAfterMs: 25_000, + rng: () => 0, + }) + assert.equal(ms, 25_000) + assert.equal(getRefreshCooldownUntil(SRC), now + 25_000) + }) + }) +}) diff --git a/src/refresh-backoff.ts b/src/refresh-backoff.ts new file mode 100644 index 0000000..5413a0f --- /dev/null +++ b/src/refresh-backoff.ts @@ -0,0 +1,134 @@ +/** + * Transient-vs-terminal classification and per-account backoff for OAuth token + * refreshes. + * + * The token endpoint (`claude.ai/v1/oauth/token`) rate-limits refresh requests + * with HTTP 429 `rate_limit_error`. That is transient — the refresh token is + * still valid — but the plugin previously treated every non-OK refresh as a + * hard failure, surfacing "credentials unavailable. Run `claude`" and then + * hammering the same endpoint (and the `claude` CLI, which hits it too). This + * module lets callers tell a transient rate-limit apart from a genuinely dead + * refresh token (`invalid_grant`), and imposes a cooldown so a rate-limited + * account is not re-hit until the window has plausibly cleared. + */ + +export type RefreshFailureKind = "transient" | "terminal" + +/** Base cooldown after the first transient failure (env-overridable). */ +export const BASE_COOLDOWN_MS = (() => { + const raw = process.env.OPENCODE_CLAUDE_AUTH_REFRESH_COOLDOWN_MS + const parsed = raw ? Number.parseInt(raw, 10) : NaN + return Number.isFinite(parsed) && parsed > 0 ? parsed : 15_000 +})() + +/** Hard ceiling for a single cooldown, regardless of consecutive failures. */ +export const MAX_COOLDOWN_MS = 60_000 + +/** + * OAuth token-endpoint error codes that mean the refresh token itself is no + * longer usable. Everything else — rate limits, 5xx, network errors, unknown + * codes — is treated as transient so a recoverable blip never surfaces as a + * hard "re-authenticate" error. + */ +const TERMINAL_OAUTH_ERRORS = new Set([ + "invalid_grant", + "invalid_client", + "unauthorized_client", + "unsupported_grant_type", +]) + +export function classifyRefreshFailure( + _status: number, + oauthError?: string, +): RefreshFailureKind { + return oauthError && TERMINAL_OAUTH_ERRORS.has(oauthError) + ? "terminal" + : "transient" +} + +interface BackoffOptions { + retryAfterMs?: number + now?: number + rng?: () => number +} + +/** + * Delay before the next refresh attempt. An explicit `retry-after` from the + * endpoint wins outright; otherwise an exponential schedule (base · 2^(n-1), + * capped) with jitter in the [50%, 100%] band to desynchronize the several + * OpenCode instances / CLI invocations that all refresh the same account. + */ +export function computeBackoffMs( + consecutive: number, + opts: BackoffOptions = {}, +): number { + if (opts.retryAfterMs !== undefined && opts.retryAfterMs > 0) { + return opts.retryAfterMs + } + const rng = opts.rng ?? Math.random + const exponent = Math.max(0, consecutive - 1) + const scheduled = Math.min(MAX_COOLDOWN_MS, BASE_COOLDOWN_MS * 2 ** exponent) + const jitterFactor = 0.5 + rng() * 0.5 + return Math.min(MAX_COOLDOWN_MS, Math.round(scheduled * jitterFactor)) +} + +interface CooldownState { + until: number + consecutive: number +} + +const cooldowns = new Map() +const lastFailureKind = new Map() + +/** + * Record a transient refresh failure for `source` and return the cooldown + * duration applied. The cooldown escalates with consecutive transient + * failures and is exposed via {@link isRefreshCooldownActive}. + */ +export function noteRefreshTransient( + source: string, + opts: BackoffOptions = {}, +): number { + const now = opts.now ?? Date.now() + const consecutive = (cooldowns.get(source)?.consecutive ?? 0) + 1 + const ms = computeBackoffMs(consecutive, opts) + cooldowns.set(source, { until: now + ms, consecutive }) + lastFailureKind.set(source, "transient") + return ms +} + +/** Record a terminal refresh failure (dead refresh token). No cooldown. */ +export function noteRefreshTerminal(source: string): void { + cooldowns.delete(source) + lastFailureKind.set(source, "terminal") +} + +/** Clear all backoff state for `source` after a successful refresh/adopt. */ +export function clearRefreshOutcome(source: string): void { + cooldowns.delete(source) + lastFailureKind.delete(source) +} + +export function isRefreshCooldownActive( + source: string, + now: number = Date.now(), +): boolean { + const state = cooldowns.get(source) + return state !== undefined && state.until > now +} + +export function getRefreshCooldownUntil(source: string): number | null { + return cooldowns.get(source)?.until ?? null +} + +export function getRefreshFailureKind( + source: string, +): RefreshFailureKind | null { + return lastFailureKind.get(source) ?? null +} + +/** Test seam: drop all in-memory backoff state. */ +export function resetRefreshBackoffState(): void { + cooldowns.clear() + lastFailureKind.clear() +} From 7602c015381eb0ee8373ba0afbc6ea3f388d7584 Mon Sep 17 00:00:00 2001 From: Christian Battaglia Date: Mon, 3 Aug 2026 11:14:53 -0400 Subject: [PATCH 3/8] fix: single-flight OAuth refresh across processes with an advisory lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin runs inside every OpenCode process, so several instances (plus the claude CLI) can all refresh the same expired token at once and bury the token endpoint in duplicate requests, which is what provokes the 429 in the first place. In-process dedup (inFlightRefreshes) cannot see across processes. Add a best-effort advisory lock file (refresh-lock module) keyed by account source. One refresher proceeds; the others wait briefly (waitForAdopt) and adopt the winner's freshly written token from the shared credential store rather than piling on. The lock is best-effort — any filesystem error degrades to refreshing without it — and carries a TTL so a crashed holder's lock is taken over rather than wedging refreshes. The lock directory is OPENCODE_CLAUDE_AUTH_REFRESH_LOCK_DIR-overridable (default the OpenCode data dir) and the TTL is OPENCODE_CLAUDE_AUTH_REFRESH_LOCK_TTL_MS (default 20s). Diagnostics: refresh_lock_acquired / _busy / _stale_takeover / _error. --- src/credentials.test.ts | 81 ++++++++++++++++++++++ src/credentials.ts | 61 ++++++++++++++++- src/index.test.ts | 7 ++ src/refresh-lock.test.ts | 102 ++++++++++++++++++++++++++++ src/refresh-lock.ts | 142 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 src/refresh-lock.test.ts create mode 100644 src/refresh-lock.ts diff --git a/src/credentials.test.ts b/src/credentials.test.ts index 052eea1..ddfd3b8 100644 --- a/src/credentials.test.ts +++ b/src/credentials.test.ts @@ -8,9 +8,11 @@ import { } from "./credentials.ts" import { Writable } from "node:stream" import { closeLogger, initLogger } from "./logger.ts" +import { acquireRefreshLock } from "./refresh-lock.ts" import { chmodSync, mkdirSync, + mkdtempSync, readFileSync, statSync, writeFileSync, @@ -20,6 +22,12 @@ import { tmpdir } from "node:os" import { join } from "node:path" import { pathToFileURL } from "node:url" +// Keep the cross-process refresh lock off the real OpenCode data dir during +// tests, and isolated to this test process. +process.env.OPENCODE_CLAUDE_AUTH_REFRESH_LOCK_DIR = mkdtempSync( + join(tmpdir(), "opencode-claude-auth-locktest-"), +) + type Creds = { accessToken: string refreshToken: string @@ -112,6 +120,11 @@ async function loadCredentialsWithCountingKeychain( await readFile(new URL("./refresh-backoff.ts", import.meta.url), "utf8"), "utf8", ) + await writeFile( + join(tempDir, "refresh-lock.ts"), + await readFile(new URL("./refresh-lock.ts", import.meta.url), "utf8"), + "utf8", + ) const rewritten = sourceCredentials .replace(/from\s+["']\.\/(\w+)\.js["']/g, 'from "./$1.ts"') .replace( @@ -1449,6 +1462,11 @@ describe("syncAuthJson file permissions", () => { ), "utf8", ) + await writeFile( + join(tempDir, "refresh-lock.ts"), + await readFile(new URL("./refresh-lock.ts", import.meta.url), "utf8"), + "utf8", + ) const rewritten = sourceCredentials.replace( /from\s+["']\.\/(\w+)\.js["']/g, 'from "./$1.ts"', @@ -1547,6 +1565,11 @@ export function buildAccountLabels(creds) { return creds.map((_, i) => \`Account ), "utf8", ) + await writeFile( + join(tempDir, "refresh-lock.ts"), + await readFile(new URL("./refresh-lock.ts", import.meta.url), "utf8"), + "utf8", + ) const rewritten = sourceCredentials.replace( /from\s+["']\.\/(\w+)\.js["']/g, 'from "./$1.ts"', @@ -2899,3 +2922,61 @@ describe("getCredentialsWithBackoff (transient rate-limit resilience)", () => { } }) }) + +describe("cross-process refresh lock (single-flight)", () => { + it("waits for and adopts a sibling's token while another process holds the lock", async () => { + const originalNow = Date.now + const originalFetch = globalThis.fetch + const now = 1_700_000_000_000 + Date.now = () => now + // If the lock path were ever bypassed, the endpoint must not hand back a + // usable token — this asserts the result came from the store, not a refresh. + globalThis.fetch = (async () => + new Response(JSON.stringify({ error: "rate_limit_error" }), { + status: 429, + headers: { "retry-after": "3600" }, + })) as typeof fetch + try { + const { credentialsModule, keychainModule } = + await loadCredentialsWithCountingKeychain(now - 1_000) + const target = makeAccount(now - 1_000) + credentialsModule.initAccounts([target]) + keychainModule.__setCredentials({ + accessToken: "existing-token", + refreshToken: "existing-refresh", + expiresAt: now - 1_000, + }) + + // The store looks stale on the up-front re-read, then a sibling (holding + // the lock) rotates it fresh on the very next read. + let reads = 0 + keychainModule.__setReadHook(() => { + reads += 1 + if (reads >= 2) { + keychainModule.__setCredentials({ + accessToken: "holder-token", + refreshToken: "holder-refresh", + expiresAt: now + 8 * 60 * 60_000, + }) + } + }) + + // A sibling process owns the refresh lock for this source. + const held = acquireRefreshLock(target.source) + assert.ok(held, "test acquires the lock to simulate another process") + try { + const adopted = await credentialsModule.refreshIfNeeded(target) + assert.equal( + adopted?.accessToken, + "holder-token", + "waits for and adopts the lock holder's freshly stored token", + ) + } finally { + held!.release() + } + } finally { + Date.now = originalNow + globalThis.fetch = originalFetch + } + }) +}) diff --git a/src/credentials.ts b/src/credentials.ts index 0e2ed54..eeb2af7 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -29,6 +29,7 @@ import { noteRefreshTransient, type RefreshFailureKind, } from "./refresh-backoff.ts" +import { acquireRefreshLock } from "./refresh-lock.ts" export type { ClaudeAccount } from "./keychain.ts" export type { ClaudeCredentials } from "./keychain.ts" @@ -511,7 +512,28 @@ export async function refreshIfNeeded( return inFlight } - const pending = performRefresh(target, creds) + // Cross-process single-flight: only one OpenCode instance / the CLI should + // hit the token endpoint at a time. If another holds the lock, wait briefly + // and adopt its result rather than piling onto an already-strained endpoint. + const lock = acquireRefreshLock(target.source) + if (!lock) { + log("refresh_lock_busy", { source: target.source }) + const adopted = await waitForAdopt(target, creds.accessToken) + if (adopted) return adopted + // The holder produced nothing within the window (likely crashed; its lock + // ages out by TTL). Defer rather than refresh lock-free, so we don't + // recreate the burst the lock exists to prevent — the request-level wait + // loop and the lock TTL drive eventual progress. + return null + } + + const pending = (async () => { + try { + return await performRefresh(target, creds) + } finally { + lock.release() + } + })() inFlightRefreshes.set(target.source, pending) try { return await pending @@ -549,6 +571,43 @@ function adoptFreshFromSource( return null } +const LOCK_ADOPT_WAIT_MS = 5_000 +const LOCK_ADOPT_POLL_MS = 250 + +interface AdoptWaitOptions { + maxMs?: number + pollMs?: number + now?: () => number + sleep?: (ms: number) => Promise +} + +/** + * Another instance holds the refresh lock and is presumably refreshing. Poll + * the shared store for the token it is about to write, up to a short budget, + * before giving up. + */ +async function waitForAdopt( + target: ClaudeAccount, + rejectedAccessToken: string, + opts: AdoptWaitOptions = {}, +): Promise { + const now = opts.now ?? Date.now + const sleep = opts.sleep ?? ((ms: number) => sleepAbortable(ms)) + const maxMs = opts.maxMs ?? LOCK_ADOPT_WAIT_MS + const pollMs = opts.pollMs ?? LOCK_ADOPT_POLL_MS + + const immediate = adoptFreshFromSource(target, rejectedAccessToken) + if (immediate) return immediate + + const deadline = now() + maxMs + while (now() < deadline) { + await sleep(pollMs) + const adopted = adoptFreshFromSource(target, rejectedAccessToken) + if (adopted) return adopted + } + return null +} + async function performRefresh( target: ClaudeAccount, creds: ClaudeCredentials, diff --git a/src/index.test.ts b/src/index.test.ts index 4bf6512..e344ad1 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -3,6 +3,7 @@ import { existsSync, writeFileSync, mkdirSync, + mkdtempSync, readFileSync, rmSync, } from "node:fs" @@ -12,6 +13,11 @@ import { join, dirname } from "node:path" import { before, describe, it } from "node:test" import { pathToFileURL } from "node:url" +// Keep the cross-process refresh lock off the real OpenCode data dir in tests. +process.env.OPENCODE_CLAUDE_AUTH_REFRESH_LOCK_DIR = mkdtempSync( + join(tmpdir(), "opencode-claude-auth-locktest-"), +) + interface ClaudeCredentials { accessToken: string refreshToken: string @@ -126,6 +132,7 @@ const SOURCE_FILES = [ "transforms.ts", "credentials.ts", "refresh-backoff.ts", + "refresh-lock.ts", "logger.ts", "http.ts", ] as const diff --git a/src/refresh-lock.test.ts b/src/refresh-lock.test.ts new file mode 100644 index 0000000..1eb0b69 --- /dev/null +++ b/src/refresh-lock.test.ts @@ -0,0 +1,102 @@ +import { describe, it, beforeEach, afterEach } from "node:test" +import assert from "node:assert/strict" +import { + mkdtempSync, + rmSync, + existsSync, + readdirSync, + writeFileSync, +} from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { acquireRefreshLock } from "./refresh-lock.ts" + +const SRC = "Claude Code-credentials" + +describe("refresh-lock", () => { + let dir: string + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "opencode-claude-auth-lock-")) + }) + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it("grants the lock to the first caller and denies a second holder", () => { + const first = acquireRefreshLock(SRC, { dir }) + assert.ok(first, "first caller acquires the lock") + const second = acquireRefreshLock(SRC, { dir }) + assert.equal(second, null, "a live holder blocks a second acquirer") + first!.release() + }) + + it("releases the lock so a later caller can acquire it", () => { + const first = acquireRefreshLock(SRC, { dir }) + assert.ok(first) + first!.release() + const second = acquireRefreshLock(SRC, { dir }) + assert.ok(second, "the lock is available again after release") + second!.release() + }) + + it("takes over a stale lock past its TTL", () => { + const held = acquireRefreshLock(SRC, { dir, ttlMs: 20_000 }) + assert.ok(held) + // The holder "crashes" without releasing; the lock file lingers. A later + // acquirer looking from far enough in the future treats it as stale. + const future = Date.now() + 60_000 + const takeover = acquireRefreshLock(SRC, { + dir, + ttlMs: 20_000, + now: () => future, + }) + assert.ok(takeover, "a stale lock is taken over") + takeover!.release() + }) + + it("does not take over a lock that is still within its TTL", () => { + const held = acquireRefreshLock(SRC, { dir, ttlMs: 60_000 }) + assert.ok(held) + const soon = Date.now() + 1_000 + const denied = acquireRefreshLock(SRC, { + dir, + ttlMs: 60_000, + now: () => soon, + }) + assert.equal(denied, null, "a fresh lock is respected") + held!.release() + }) + + it("keeps locks for different sources independent", () => { + const a = acquireRefreshLock("source-a", { dir }) + const b = acquireRefreshLock("source-b", { dir }) + assert.ok(a, "source-a acquires") + assert.ok(b, "source-b acquires independently") + a!.release() + b!.release() + }) + + it("removes the lock file on release", () => { + const lock = acquireRefreshLock(SRC, { dir }) + assert.ok(lock) + assert.equal(readdirSync(dir).length, 1, "a lock file exists while held") + lock!.release() + assert.equal( + readdirSync(dir).filter((f) => f.endsWith(".lock")).length, + 0, + "the lock file is removed on release", + ) + }) + + it("degrades to best-effort (grants) when the lock dir is unusable", () => { + // Point at a path whose parent is a file, so mkdir/open cannot create the + // lock. The lock must never block a refresh — it grants a no-op handle. + const filePath = join(dir, "not-a-dir") + // create a regular file, then use a path underneath it as the lock dir + writeFileSync(filePath, "x") + const lock = acquireRefreshLock(SRC, { dir: join(filePath, "sub") }) + assert.ok(lock, "an unusable lock dir degrades to a granted no-op lock") + lock!.release() + assert.ok(!existsSync(join(filePath, "sub"))) + }) +}) diff --git a/src/refresh-lock.ts b/src/refresh-lock.ts new file mode 100644 index 0000000..0857678 --- /dev/null +++ b/src/refresh-lock.ts @@ -0,0 +1,142 @@ +/** + * Best-effort cross-process single-flight lock for OAuth token refreshes. + * + * The plugin runs inside every OpenCode process, so several instances (plus the + * `claude` CLI) can all decide to refresh the same expired token at once and + * bury the endpoint in duplicate requests — the token endpoint answers the pile + * with HTTP 429. An advisory lock file lets exactly one refresher proceed; the + * others wait briefly and adopt the winner's freshly written token from the + * shared credential store. + * + * "Best-effort" is deliberate: any filesystem error degrades to running the + * refresh without a lock rather than blocking it. A crashed holder cannot + * wedge the system either — the lock carries a TTL and a stale one is taken + * over. + */ +import { + closeSync, + mkdirSync, + openSync, + statSync, + unlinkSync, + writeSync, +} from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" +import { createHash } from "node:crypto" +import { log } from "./logger.ts" + +/** How long before a held lock is considered stale (env-overridable). */ +export const DEFAULT_LOCK_TTL_MS = (() => { + const raw = process.env.OPENCODE_CLAUDE_AUTH_REFRESH_LOCK_TTL_MS + const parsed = raw ? Number.parseInt(raw, 10) : NaN + return Number.isFinite(parsed) && parsed > 0 ? parsed : 20_000 +})() + +export interface RefreshLock { + release(): void +} + +export interface AcquireOptions { + /** Directory to hold lock files in. Defaults to the OpenCode data dir. */ + dir?: string + /** Staleness threshold in ms. Defaults to {@link DEFAULT_LOCK_TTL_MS}. */ + ttlMs?: number + now?: () => number +} + +function defaultLockDir(): string { + // Read at call time so tests (and unusual deployments) can redirect the lock + // directory without reloading the module. + return ( + process.env.OPENCODE_CLAUDE_AUTH_REFRESH_LOCK_DIR ?? + join(homedir(), ".local", "share", "opencode") + ) +} + +function lockPathFor(source: string, dir: string): string { + const digest = createHash("sha256").update(source).digest("hex").slice(0, 16) + return join(dir, `claude-auth-refresh-${digest}.lock`) +} + +const NOOP_LOCK: RefreshLock = { release() {} } + +/** + * Try to acquire the refresh lock for `source`. + * + * Returns a {@link RefreshLock} when this process may refresh (either it won the + * lock, or a filesystem error made the lock unavailable and we degrade to + * best-effort). Returns null when a live holder currently owns it — the caller + * should wait and adopt the holder's result instead of refreshing. + */ +export function acquireRefreshLock( + source: string, + opts: AcquireOptions = {}, +): RefreshLock | null { + const dir = opts.dir ?? defaultLockDir() + const ttlMs = opts.ttlMs ?? DEFAULT_LOCK_TTL_MS + const now = opts.now ?? Date.now + const path = lockPathFor(source, dir) + + try { + mkdirSync(dir, { recursive: true }) + } catch { + // Non-fatal: openSync below will surface a real problem. + } + + // Two attempts: the second only runs after clearing a stale lock. + for (let attempt = 0; attempt < 2; attempt++) { + let fd: number + try { + fd = openSync(path, "wx") + } catch (err) { + const code = (err as NodeJS.ErrnoException).code + if (code !== "EEXIST") { + // Unexpected FS failure — never let the lock block a refresh. + log("refresh_lock_error", { source, error: String(code ?? err) }) + return NOOP_LOCK + } + // Someone holds it. Take over only if it is stale. + let stale = false + try { + stale = now() - statSync(path).mtimeMs > ttlMs + } catch { + // Vanished between open and stat — retry the acquire. + stale = true + } + if (stale) { + log("refresh_lock_stale_takeover", { source }) + try { + unlinkSync(path) + } catch { + // Lost the race to remove it; the next attempt/stat settles it. + } + continue + } + return null + } + + try { + writeSync(fd, JSON.stringify({ pid: process.pid, ts: now() })) + } catch { + // The lock is held regardless of whether the payload wrote. + } + log("refresh_lock_acquired", { source }) + return { + release() { + try { + closeSync(fd) + } catch { + // already closed + } + try { + unlinkSync(path) + } catch { + // already gone (e.g. a stale-takeover removed it) + } + }, + } + } + + return null +} From 4668da9847ea6a0cadb4a5afbf6d38842bc04dce Mon Sep 17 00:00:00 2001 From: Christian Battaglia Date: Mon, 3 Aug 2026 11:16:20 -0400 Subject: [PATCH 4/8] fix: honor an absolute expires_at in the OAuth refresh response parseOAuthResponse only read the relative expires_in, defaulting to 10h when absent. If the token endpoint returns an absolute expires_at instead, the plugin would mis-set the access-token lifetime and later 401 on a token it believed valid. Prefer a future millisecond expires_at when present, and fall back to expires_in (or the default) for a missing or seconds-precision value that would otherwise read as already-expired. --- src/credentials.test.ts | 25 +++++++++++++++++++++++++ src/credentials.ts | 13 ++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/credentials.test.ts b/src/credentials.test.ts index ddfd3b8..c8e1e5d 100644 --- a/src/credentials.test.ts +++ b/src/credentials.test.ts @@ -2780,6 +2780,31 @@ describe("parseOAuthResponse", () => { assert.equal(Number.isInteger(result.expiresAt), true) }) + it("honors an absolute future expires_at (ms) over expires_in", () => { + const expiresAt = now + 8 * 60 * 60_000 + const raw = JSON.stringify({ + access_token: "sk-ant-oat01-new", + expires_in: 60, // deliberately tiny; expires_at should win + expires_at: expiresAt, + }) + const result = parseOAuthResponse(raw, currentRefresh, now) + assert.ok(result) + assert.equal(result.expiresAt, expiresAt) + }) + + it("ignores a non-future (e.g. seconds-precision) expires_at and falls back to expires_in", () => { + // A seconds-precision value read as ms lands in 1970 (<= now); must not be + // used, or the token would read as already-expired. + const raw = JSON.stringify({ + access_token: "sk-ant-oat01-new", + expires_in: 28_800, + expires_at: 1_900_000_000, // seconds, not ms + }) + const result = parseOAuthResponse(raw, currentRefresh, now) + assert.ok(result) + assert.equal(result.expiresAt, now + 28_800 * 1000) + }) + it("returns null when access_token is missing", () => { const raw = JSON.stringify({ refresh_token: "rt", expires_in: 3600 }) assert.equal(parseOAuthResponse(raw, currentRefresh, now), null) diff --git a/src/credentials.ts b/src/credentials.ts index eeb2af7..771a252 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -191,6 +191,8 @@ export function parseOAuthResponse( access_token?: string refresh_token?: string expires_in?: number + // eslint-disable-next-line @typescript-eslint/naming-convention + expires_at?: number error?: string } try { @@ -201,10 +203,19 @@ export function parseOAuthResponse( if (!data.access_token) return null + // Prefer an absolute `expires_at` (ms) when the endpoint provides one, but + // only if it is a future millisecond timestamp — a seconds-precision value + // would land in 1970 and read as already-expired, so fall back to the + // relative `expires_in` (or a conservative default) in that case. + const expiresAt = + typeof data.expires_at === "number" && data.expires_at > now + ? Math.trunc(data.expires_at) + : Math.trunc(now + (data.expires_in ?? 36_000) * 1000) + return { accessToken: data.access_token, refreshToken: data.refresh_token ?? currentRefreshToken, - expiresAt: Math.trunc(now + (data.expires_in ?? 36_000) * 1000), + expiresAt, } } From 8babc55c146fd58637ab482db0fe16474207c297 Mon Sep 17 00:00:00 2001 From: Christian Battaglia Date: Mon, 3 Aug 2026 11:16:57 -0400 Subject: [PATCH 5/8] docs: document the OAuth refresh resilience env knobs --- README.md | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 4979a26..91b4430 100644 --- a/README.md +++ b/README.md @@ -151,14 +151,18 @@ This reads your stored credentials, calls Anthropic's OAuth token endpoint, and All configurable parameters can be overridden via environment variables. If Anthropic changes something before we publish an update, set an env var and keep working: -| Variable | Description | Default | -| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | -| `ANTHROPIC_CLI_VERSION` | Claude CLI version for user-agent and billing headers | `config.ccVersion` in [`src/model-config.ts`](src/model-config.ts) | -| `ANTHROPIC_USER_AGENT` | Full User-Agent string (overrides CLI version) | `claude-cli/{version} (external, sdk-cli)` | -| `ANTHROPIC_BETA_FLAGS` | Comma-separated beta feature flags | `baseBetas` list in [`src/model-config.ts`](src/model-config.ts) | -| `CLAUDE_AUTH_DEBUG` | Enable diagnostic logging (`1` for default path, or a custom file path) | disabled | -| `CLAUDE_CONFIG_DIR` | Claude Code config directory used for the credentials-file fallback (reads `$CLAUDE_CONFIG_DIR/.credentials.json`). macOS still checks the Keychain first. | `~/.claude` | -| `OPENCODE_CLAUDE_AUTH_MAX_RETRY_MS` | Max ms the plugin waits when honouring a 429/529 `retry-after` header. Beyond this cap the response surfaces immediately so OpenCode doesn't appear to hang on hour-long quota resets. | `30000` | +| Variable | Description | Default | +| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| `ANTHROPIC_CLI_VERSION` | Claude CLI version for user-agent and billing headers | `config.ccVersion` in [`src/model-config.ts`](src/model-config.ts) | +| `ANTHROPIC_USER_AGENT` | Full User-Agent string (overrides CLI version) | `claude-cli/{version} (external, sdk-cli)` | +| `ANTHROPIC_BETA_FLAGS` | Comma-separated beta feature flags | `baseBetas` list in [`src/model-config.ts`](src/model-config.ts) | +| `CLAUDE_AUTH_DEBUG` | Enable diagnostic logging (`1` for default path, or a custom file path) | disabled | +| `CLAUDE_CONFIG_DIR` | Claude Code config directory used for the credentials-file fallback (reads `$CLAUDE_CONFIG_DIR/.credentials.json`). macOS still checks the Keychain first. | `~/.claude` | +| `OPENCODE_CLAUDE_AUTH_MAX_RETRY_MS` | Max ms the plugin waits when honouring a 429/529 `retry-after` header. Beyond this cap the response surfaces immediately so OpenCode doesn't appear to hang on hour-long quota resets. | `30000` | +| `OPENCODE_CLAUDE_AUTH_REFRESH_WAIT_MS` | Max ms a single request waits through a transient token-refresh rate-limit (429) before returning a retryable error instead of a hard "run `claude`". The request returns as soon as the cooldown clears or a sibling instance/CLI writes a fresh token. | `45000` | +| `OPENCODE_CLAUDE_AUTH_REFRESH_COOLDOWN_MS` | Base per-account cooldown after a rate-limited refresh, before the plugin retries the token endpoint. Escalates with consecutive failures and is jittered; capped at 60s. | `15000` | +| `OPENCODE_CLAUDE_AUTH_REFRESH_LOCK_TTL_MS` | TTL for the cross-process refresh lock. A held lock older than this is treated as stale (crashed holder) and taken over. | `20000` | +| `OPENCODE_CLAUDE_AUTH_REFRESH_LOCK_DIR` | Directory for the advisory cross-process refresh lock files. | OpenCode data dir (`~/.local/share/opencode`) | Example: From 8539c8f755cc0e682e59fa3ad845a40feaee203c Mon Sep 17 00:00:00 2001 From: Christian Battaglia Date: Mon, 3 Aug 2026 14:38:13 -0400 Subject: [PATCH 6/8] fix: harden extractOAuthError against non-object JSON bodies (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A token endpoint returning a JSON primitive or array (e.g. the literal body `null`) made `JSON.parse` succeed with a non-object value, so the subsequent `data.error` dereference threw. That TypeError escaped extractOAuthError into refreshViaOAuthDetailed's outer catch, which then logged the TypeError instead of the real HTTP status — erasing the diagnostic context this path exists to capture. Guard for object bodies after the parse. Also document that the flat OAuth-standard `error_description` intentionally wins over a nested-envelope `message`, with a test for the mixed shape. --- src/credentials.test.ts | 18 ++++++++++++++++++ src/credentials.ts | 10 ++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/credentials.test.ts b/src/credentials.test.ts index c8e1e5d..116d3d4 100644 --- a/src/credentials.test.ts +++ b/src/credentials.test.ts @@ -1855,6 +1855,24 @@ describe("extractOAuthError", () => { assert.equal(result.oauthError, "server_error") assert.equal(result.oauthErrorDescription?.length, 500) }) + + it("returns an empty object for JSON primitives and arrays without throwing", () => { + // JSON.parse("null") === null etc. — must not crash the error-logging path. + for (const body of ["null", "123", '"a string"', "[1,2,3]", "true"]) { + assert.deepEqual(extractOAuthError(body), {}, `body: ${body}`) + } + }) + + it("prefers the flat error_description over a nested message when both are present", () => { + const result = extractOAuthError( + JSON.stringify({ + error: { type: "foo", message: "nested" }, + error_description: "flat", + }), + ) + assert.equal(result.oauthError, "foo") + assert.equal(result.oauthErrorDescription, "flat") + }) }) function makeAccount(expiresAt: number) { diff --git a/src/credentials.ts b/src/credentials.ts index 771a252..902f5ed 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -241,6 +241,14 @@ export function extractOAuthError(raw: string): { return {} } + // JSON.parse succeeds for primitives and arrays too (`null`, `123`, `"str"`, + // `[...]`); dereferencing `data.error` on those would throw and, worse, + // escape into refreshViaOAuthDetailed's outer catch — erasing the HTTP status + // this function exists to preserve. Only object bodies carry an error shape. + if (typeof data !== "object" || data === null || Array.isArray(data)) { + return {} + } + const out: { oauthError?: string; oauthErrorDescription?: string } = {} if (typeof data.error === "string") { out.oauthError = data.error.slice(0, 200) @@ -252,6 +260,8 @@ export function extractOAuthError(raw: string): { out.oauthErrorDescription = nested.message.slice(0, 500) } } + // The flat OAuth-standard `error_description` is canonical, so it deliberately + // wins over a nested-envelope `message` when a response carries both. if (typeof data.error_description === "string") { out.oauthErrorDescription = data.error_description.slice(0, 500) } From fc1ce396b53091d7b49f1cd38794f1eb35122be1 Mon Sep 17 00:00:00 2001 From: Christian Battaglia Date: Mon, 3 Aug 2026 15:25:24 -0400 Subject: [PATCH 7/8] fix: clamp a server retry-after to the cooldown cap (review) computeBackoffMs capped the exponential schedule but returned retryAfterMs uncapped, contradicting the documented 60s cap. A Retry-After: 3600 (which fetchWithRetry passes through once it exceeds the max-retry window) would set a one-hour cooldown, making every request block the full REFRESH_WAIT_MS before returning a 429 for that whole window. Clamp retryAfterMs to MAX_COOLDOWN_MS. --- src/refresh-backoff.test.ts | 8 ++++++++ src/refresh-backoff.ts | 8 ++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/refresh-backoff.test.ts b/src/refresh-backoff.test.ts index d3b1f75..c4d6885 100644 --- a/src/refresh-backoff.test.ts +++ b/src/refresh-backoff.test.ts @@ -58,6 +58,14 @@ describe("refresh-backoff", () => { ) }) + it("clamps a large retry-after to the cap", () => { + // A server sending Retry-After: 3600 must not pin the cooldown to an hour. + assert.equal( + computeBackoffMs(1, { retryAfterMs: 3_600_000, rng: () => 0 }), + MAX_COOLDOWN_MS, + ) + }) + it("grows exponentially with consecutive failures and is capped", () => { const a = computeBackoffMs(1, { rng: () => 0 }) const b = computeBackoffMs(2, { rng: () => 0 }) diff --git a/src/refresh-backoff.ts b/src/refresh-backoff.ts index 5413a0f..408ace3 100644 --- a/src/refresh-backoff.ts +++ b/src/refresh-backoff.ts @@ -54,7 +54,8 @@ interface BackoffOptions { /** * Delay before the next refresh attempt. An explicit `retry-after` from the - * endpoint wins outright; otherwise an exponential schedule (base · 2^(n-1), + * endpoint wins (still clamped to `MAX_COOLDOWN_MS`); otherwise an exponential + * schedule (base · 2^(n-1), * capped) with jitter in the [50%, 100%] band to desynchronize the several * OpenCode instances / CLI invocations that all refresh the same account. */ @@ -63,7 +64,10 @@ export function computeBackoffMs( opts: BackoffOptions = {}, ): number { if (opts.retryAfterMs !== undefined && opts.retryAfterMs > 0) { - return opts.retryAfterMs + // Honor the server's hint, but keep it under the documented cap so a large + // `Retry-After` (e.g. an hour-long quota reset) can't pin every request to + // the full wait budget for that whole window. + return Math.min(MAX_COOLDOWN_MS, opts.retryAfterMs) } const rng = opts.rng ?? Math.random const exponent = Math.max(0, consecutive - 1) From befd60a401b29b25bb3cd9ed1156640254ec9c88 Mon Sep 17 00:00:00 2001 From: Christian Battaglia Date: Mon, 3 Aug 2026 16:52:07 -0400 Subject: [PATCH 8/8] fix: fail fast in getCredentialsWithBackoff when no account is active (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When getActiveAccount() returned null, `source` was undefined, the terminal-failure early-exit (guarded by `if (source && ...)`) was skipped, and the function spun the full maxWaitMs (~45s) polling getCachedCredentials — which also returns null with no account — before failing. That regressed the previously-immediate hard error into a 45s hang. Return null right away when there is no active account. --- src/credentials.test.ts | 26 ++++++++++++++++++++++++++ src/credentials.ts | 5 ++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/credentials.test.ts b/src/credentials.test.ts index 116d3d4..a642644 100644 --- a/src/credentials.test.ts +++ b/src/credentials.test.ts @@ -2887,6 +2887,32 @@ describe("getCredentialsWithBackoff (transient rate-limit resilience)", () => { } }) + it("fails fast (no wait) when there is no active account", async () => { + const originalNow = Date.now + const now = 1_700_000_000_000 + Date.now = () => now + try { + const { credentialsModule } = await loadCredentialsWithCountingKeychain( + now + 10 * 60_000, + ) + credentialsModule.initAccounts([]) // no accounts configured + + let slept = 0 + const creds = await credentialsModule.getCredentialsWithBackoff({ + maxWaitMs: 100_000, + now: () => now, + sleep: async () => { + slept += 1 + }, + }) + + assert.equal(creds, null) + assert.equal(slept, 0, "no account means nothing to wait for") + } finally { + Date.now = originalNow + } + }) + it("returns null promptly on a terminal failure, without exhausting the wait", async () => { const originalFetch = globalThis.fetch const originalNow = Date.now diff --git a/src/credentials.ts b/src/credentials.ts index 902f5ed..ea3c1cd 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -1099,8 +1099,11 @@ export async function getCredentialsWithBackoff( if (first) return first const source = getActiveAccount()?.source + // No active account means no in-progress refresh could ever produce a token, + // so waiting is pointless — fail fast instead of spinning the wait budget. + if (!source) return null // A dead refresh token will not fix itself by waiting. - if (source && getRefreshFailureKind(source) === "terminal") return null + if (getRefreshFailureKind(source) === "terminal") return null const now = opts.now ?? Date.now const sleep = opts.sleep ?? sleepAbortable