opencode-claude-auth 2.1.5 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -55,11 +55,12 @@ Just run OpenCode. The plugin handles auth automatically — it reads your Claud
55
55
 
56
56
  ## Supported models
57
57
 
58
- 13 supported models. Run `pnpm run test:models` to verify against your account.
58
+ 14 supported models. Run `pnpm run test:models` to verify against your account.
59
59
 
60
60
  | Model |
61
61
  | -------------------------- |
62
62
  | claude-fable-5 |
63
+ | claude-fable-5-1 |
63
64
  | claude-haiku-4-5 |
64
65
  | claude-haiku-4-5-20251001 |
65
66
  | claude-opus-4-5 |
@@ -151,14 +152,19 @@ This reads your stored credentials, calls Anthropic's OAuth token endpoint, and
151
152
 
152
153
  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:
153
154
 
154
- | Variable | Description | Default |
155
- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
156
- | `ANTHROPIC_CLI_VERSION` | Claude CLI version for user-agent and billing headers | `config.ccVersion` in [`src/model-config.ts`](src/model-config.ts) |
157
- | `ANTHROPIC_USER_AGENT` | Full User-Agent string (overrides CLI version) | `claude-cli/{version} (external, sdk-cli)` |
158
- | `ANTHROPIC_BETA_FLAGS` | Comma-separated beta feature flags | `baseBetas` list in [`src/model-config.ts`](src/model-config.ts) |
159
- | `CLAUDE_AUTH_DEBUG` | Enable diagnostic logging (`1` for default path, or a custom file path) | disabled |
160
- | `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` |
161
- | `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` |
155
+ | Variable | Description | Default |
156
+ | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
157
+ | `ANTHROPIC_CLI_VERSION` | Claude CLI version for user-agent and billing headers | `config.ccVersion` in [`src/model-config.ts`](src/model-config.ts) |
158
+ | `ANTHROPIC_USER_AGENT` | Full User-Agent string (overrides CLI version) | `claude-cli/{version} (external, sdk-cli)` |
159
+ | `ANTHROPIC_BETA_FLAGS` | Comma-separated beta feature flags | `baseBetas` list in [`src/model-config.ts`](src/model-config.ts) |
160
+ | `CLAUDE_AUTH_DEBUG` | Enable diagnostic logging (`1` for default path, or a custom file path) | disabled |
161
+ | `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` |
162
+ | `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` |
163
+ | `OPENCODE_CLAUDE_AUTH_TOOL_REPAIR` | Strategy for reconciling `tool_use`/`tool_result` adjacency broken by OpenCode auto-compaction. `placeholder` synthesizes a paired result for orphaned `tool_use` blocks (lossless, preserves `thinking` blocks); `drop` removes orphaned blocks (omitting whole thinking turns). | `placeholder` |
164
+ | `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`". | `45000` |
165
+ | `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` |
166
+ | `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` |
167
+ | `OPENCODE_CLAUDE_AUTH_REFRESH_LOCK_DIR` | Directory for the advisory cross-process refresh lock files. | OpenCode data dir (`~/.local/share/opencode`) |
162
168
 
163
169
  Example:
164
170
 
@@ -1,4 +1,5 @@
1
1
  import { type ClaudeAccount, type ClaudeCredentials } from "./keychain.ts";
2
+ import { type RefreshFailureKind } from "./refresh-backoff.ts";
2
3
  export type { ClaudeAccount } from "./keychain.ts";
3
4
  export type { ClaudeCredentials } from "./keychain.ts";
4
5
  export declare function initAccounts(accounts: ClaudeAccount[]): void;
@@ -11,6 +12,17 @@ export declare function syncAuthJson(creds: ClaudeCredentials): void;
11
12
  export declare const OAUTH_TOKEN_URL = "https://claude.ai/v1/oauth/token";
12
13
  export declare const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
13
14
  export declare function parseOAuthResponse(raw: string, currentRefreshToken: string, now?: number): ClaudeCredentials | null;
15
+ /**
16
+ * Extract the non-secret failure reason from an OAuth token-endpoint error
17
+ * body so a refresh failure is diagnosable from the debug log. Handles both the
18
+ * OAuth shape (`{ error, error_description }`) and Anthropic's API error
19
+ * envelope (`{ error: { type, message } }`). Values are truncated and never
20
+ * include tokens; the logger additionally redacts anything JWT-shaped.
21
+ */
22
+ export declare function extractOAuthError(raw: string): {
23
+ oauthError?: string;
24
+ oauthErrorDescription?: string;
25
+ };
14
26
  /**
15
27
  * Exchanges a refresh token for fresh credentials using the runtime's own
16
28
  * fetch.
@@ -23,6 +35,35 @@ export declare function parseOAuthResponse(raw: string, currentRefreshToken: str
23
35
  * non-zero with empty stdout and silently fell through to the claude CLI.
24
36
  * Node 18+ and Bun both expose a global fetch, so no subprocess is needed.
25
37
  */
38
+ /**
39
+ * Classified result of an OAuth refresh. A `transient` outcome (429/5xx/network
40
+ * /`rate_limit_error`) means the refresh token is still good and the caller
41
+ * should back off and retry rather than surface a hard error; a `terminal`
42
+ * outcome (`invalid_grant`, ...) means the refresh token is dead.
43
+ */
44
+ export type RefreshOutcome = {
45
+ kind: "ok";
46
+ creds: ClaudeCredentials;
47
+ } | {
48
+ kind: "transient";
49
+ status: number;
50
+ oauthError?: string;
51
+ retryAfterMs?: number;
52
+ } | {
53
+ kind: "terminal";
54
+ status: number;
55
+ oauthError?: string;
56
+ };
57
+ /**
58
+ * Exchange a refresh token for fresh credentials and classify the result.
59
+ * See {@link RefreshOutcome}. Uses the runtime's own fetch (no subprocess).
60
+ */
61
+ export declare function refreshViaOAuthDetailed(refreshToken: string, timeoutMs?: number): Promise<RefreshOutcome>;
62
+ /**
63
+ * Backward-compatible wrapper: returns credentials on success, else null.
64
+ * Prefer {@link refreshViaOAuthDetailed} when the transient/terminal
65
+ * distinction matters (cooldown, CLI-fallback gating).
66
+ */
26
67
  export declare function refreshViaOAuth(refreshToken: string, timeoutMs?: number): Promise<ClaudeCredentials | null>;
27
68
  /**
28
69
  * Refreshes the given (or active) account's credentials if they are within
@@ -64,5 +105,29 @@ export declare function forceRefreshActiveAccount(refresh?: (refreshToken: strin
64
105
  */
65
106
  export declare function invalidateCredentialCache(): void;
66
107
  export declare function getCachedCredentials(): Promise<ClaudeCredentials | null>;
108
+ export interface CredentialWaitOptions {
109
+ maxWaitMs?: number;
110
+ pollMs?: number;
111
+ signal?: AbortSignal;
112
+ now?: () => number;
113
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
114
+ rng?: () => number;
115
+ }
116
+ /**
117
+ * Resolve credentials, waiting through a transient refresh rate-limit rather
118
+ * than failing hard. Returns as soon as a token is available — ours refreshed
119
+ * once the cooldown clears, or a sibling OpenCode instance / the `claude` CLI
120
+ * wrote a fresh one to the shared store. Returns null promptly on a terminal
121
+ * failure (dead refresh token) or when the wait budget is exhausted, so the
122
+ * caller can decide between a retryable response and a hard error.
123
+ */
124
+ export declare function getCredentialsWithBackoff(opts?: CredentialWaitOptions): Promise<ClaudeCredentials | null>;
125
+ /**
126
+ * Whether the active account's most recent refresh failure was transient
127
+ * (rate-limited/retryable) or terminal (dead refresh token), for callers
128
+ * deciding between a retryable response and a hard "re-authenticate" error.
129
+ * An active cooldown implies a transient failure.
130
+ */
131
+ export declare function getActiveRefreshFailureKind(): RefreshFailureKind | null;
67
132
  export declare function reloadCredentialsFromSource(): ClaudeCredentials | null;
68
133
  //# sourceMappingURL=credentials.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"credentials.d.ts","sourceRoot":"","sources":["../src/credentials.ts"],"names":[],"mappings":"AAUA,OAAO,EAKL,KAAK,aAAa,EAClB,KAAK,iBAAiB,EACvB,MAAM,eAAe,CAAA;AAKtB,YAAY,EAAE,aAAa,EAAE,MAAM,eAAe,CAAA;AAClD,YAAY,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAA;AAqBtD,wBAAgB,YAAY,CAAC,QAAQ,EAAE,aAAa,EAAE,GAAG,IAAI,CAE5D;AAED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAQ3D;AAED,wBAAgB,mBAAmB,IAAI,aAAa,EAAE,CAUrD;AAED,wBAAgB,gBAAgB,IAAI,aAAa,GAAG,IAAI,CAOvD;AAYD,wBAAgB,0BAA0B,IAAI,MAAM,GAAG,IAAI,CAU1D;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAStD;AA4CD,wBAAgB,YAAY,CAAC,KAAK,EAAE,iBAAiB,GAAG,IAAI,CAc3D;AAED,eAAO,MAAM,eAAe,qCAAqC,CAAA;AACjE,eAAO,MAAM,eAAe,yCAAyC,CAAA;AAErE,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,MAAM,EACX,mBAAmB,EAAE,MAAM,EAC3B,GAAG,GAAE,MAAmB,GACvB,iBAAiB,GAAG,IAAI,CAoB1B;AAID;;;;;;;;;;;GAWG;AACH,wBAAsB,eAAe,CACnC,YAAY,EAAE,MAAM,EACpB,SAAS,SAAmB,GAC3B,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAmDnC;AA0CD;;;;;;;;GAQG;AACH,wBAAsB,eAAe,CACnC,OAAO,CAAC,EAAE,aAAa,EACvB,WAAW,SAAS,GACnB,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CA2EnC;AA6OD,wBAAgB,qBAAqB,IAAI,iBAAiB,GAAG,IAAI,CAUhE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,IAAI,IAAI,CAY1C;AAED;;;;;;GAMG;AACH,wBAAsB,yBAAyB,CAC7C,OAAO,GAAE,CACP,YAAY,EAAE,MAAM,KACjB,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAmB,GACvD,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAyCnC;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,IAAI,IAAI,CAMhD;AAED,wBAAsB,oBAAoB,IAAI,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAgC9E;AAED,wBAAgB,2BAA2B,IAAI,iBAAiB,GAAG,IAAI,CAmDtE"}
1
+ {"version":3,"file":"credentials.d.ts","sourceRoot":"","sources":["../src/credentials.ts"],"names":[],"mappings":"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"}
@@ -6,6 +6,8 @@ import { PRIMARY_SERVICE, readAllClaudeAccounts, refreshAccount, writeBackCreden
6
6
  import { resetExcludedBetas } from "./betas.js";
7
7
  import { fetchWithRetry } from "./http.js";
8
8
  import { log } from "./logger.js";
9
+ import { classifyRefreshFailure, clearRefreshOutcome, getRefreshCooldownUntil, getRefreshFailureKind, isRefreshCooldownActive, noteRefreshTerminal, noteRefreshTransient, } from "./refresh-backoff.js";
10
+ import { acquireRefreshLock } from "./refresh-lock.js";
9
11
  const CREDENTIAL_CACHE_TTL_MS = 30_000;
10
12
  // Only inside this window will the claude CLI actually rotate a token, so
11
13
  // it is also the only window where spawning it is worth a real API request.
@@ -146,26 +148,72 @@ export function parseOAuthResponse(raw, currentRefreshToken, now = Date.now()) {
146
148
  }
147
149
  if (!data.access_token)
148
150
  return null;
151
+ // Prefer an absolute `expires_at` (ms) when the endpoint provides one, but
152
+ // only if it is a future millisecond timestamp — a seconds-precision value
153
+ // would land in 1970 and read as already-expired, so fall back to the
154
+ // relative `expires_in` (or a conservative default) in that case.
155
+ const expiresAt = typeof data.expires_at === "number" && data.expires_at > now
156
+ ? Math.trunc(data.expires_at)
157
+ : Math.trunc(now + (data.expires_in ?? 36_000) * 1000);
149
158
  return {
150
159
  accessToken: data.access_token,
151
160
  refreshToken: data.refresh_token ?? currentRefreshToken,
152
- expiresAt: Math.trunc(now + (data.expires_in ?? 36_000) * 1000),
161
+ expiresAt,
153
162
  };
154
163
  }
164
+ /**
165
+ * Extract the non-secret failure reason from an OAuth token-endpoint error
166
+ * body so a refresh failure is diagnosable from the debug log. Handles both the
167
+ * OAuth shape (`{ error, error_description }`) and Anthropic's API error
168
+ * envelope (`{ error: { type, message } }`). Values are truncated and never
169
+ * include tokens; the logger additionally redacts anything JWT-shaped.
170
+ */
171
+ export function extractOAuthError(raw) {
172
+ let data;
173
+ try {
174
+ data = JSON.parse(raw);
175
+ }
176
+ catch {
177
+ return {};
178
+ }
179
+ // JSON.parse succeeds for primitives and arrays too (`null`, `123`, `"str"`,
180
+ // `[...]`); dereferencing `data.error` on those would throw and, worse,
181
+ // escape into refreshViaOAuthDetailed's outer catch — erasing the HTTP status
182
+ // this function exists to preserve. Only object bodies carry an error shape.
183
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
184
+ return {};
185
+ }
186
+ const out = {};
187
+ if (typeof data.error === "string") {
188
+ out.oauthError = data.error.slice(0, 200);
189
+ }
190
+ else if (data.error && typeof data.error === "object") {
191
+ const nested = data.error;
192
+ if (typeof nested.type === "string")
193
+ out.oauthError = nested.type.slice(0, 200);
194
+ if (typeof nested.message === "string") {
195
+ out.oauthErrorDescription = nested.message.slice(0, 500);
196
+ }
197
+ }
198
+ // The flat OAuth-standard `error_description` is canonical, so it deliberately
199
+ // wins over a nested-envelope `message` when a response carries both.
200
+ if (typeof data.error_description === "string") {
201
+ out.oauthErrorDescription = data.error_description.slice(0, 500);
202
+ }
203
+ return out;
204
+ }
155
205
  const OAUTH_TIMEOUT_MS = 15_000;
206
+ function parseRetryAfterMs(headerValue) {
207
+ if (!headerValue)
208
+ return undefined;
209
+ const seconds = Number.parseInt(headerValue, 10);
210
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : undefined;
211
+ }
156
212
  /**
157
- * Exchanges a refresh token for fresh credentials using the runtime's own
158
- * fetch.
159
- *
160
- * This previously ran the request inside a child process spawned as
161
- * `process.execPath -e <script>`. That assumed process.execPath is a
162
- * JavaScript runtime, which does not hold inside OpenCode: the plugin runs
163
- * in a compiled single-file executable, so process.execPath is the OpenCode
164
- * binary itself and `-e` is not a script to evaluate. Every refresh exited
165
- * non-zero with empty stdout and silently fell through to the claude CLI.
166
- * Node 18+ and Bun both expose a global fetch, so no subprocess is needed.
213
+ * Exchange a refresh token for fresh credentials and classify the result.
214
+ * See {@link RefreshOutcome}. Uses the runtime's own fetch (no subprocess).
167
215
  */
168
- export async function refreshViaOAuth(refreshToken, timeoutMs = OAUTH_TIMEOUT_MS) {
216
+ export async function refreshViaOAuthDetailed(refreshToken, timeoutMs = OAUTH_TIMEOUT_MS) {
169
217
  const body = new URLSearchParams({
170
218
  grant_type: "refresh_token",
171
219
  client_id: OAUTH_CLIENT_ID,
@@ -175,10 +223,6 @@ export async function refreshViaOAuth(refreshToken, timeoutMs = OAUTH_TIMEOUT_MS
175
223
  const timer = setTimeout(() => controller.abort(), timeoutMs);
176
224
  try {
177
225
  log("refresh_started", { source: "oauth" });
178
- // The token endpoint rate-limits valid refresh requests, and several
179
- // OpenCode instances refreshing near expiry cluster their calls, so a
180
- // 429 here is transient rather than terminal. The shared helper caps
181
- // its own backoff, and the abort signal bounds the whole sequence.
182
226
  const response = await fetchWithRetry(OAUTH_TOKEN_URL, {
183
227
  method: "POST",
184
228
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -186,34 +230,63 @@ export async function refreshViaOAuth(refreshToken, timeoutMs = OAUTH_TIMEOUT_MS
186
230
  signal: controller.signal,
187
231
  });
188
232
  if (!response.ok) {
233
+ // Capture the token endpoint's own failure reason (invalid_grant,
234
+ // invalid_client, rate_limit_error, ...) so a persistent 401 is
235
+ // diagnosable rather than an opaque "HTTP 400".
236
+ const detail = extractOAuthError(await response.text().catch(() => ""));
237
+ const kind = classifyRefreshFailure(response.status, detail.oauthError);
238
+ const retryAfterMs = parseRetryAfterMs(response.headers.get("retry-after"));
189
239
  log("refresh_failed", {
190
240
  source: "oauth",
191
241
  error: `HTTP ${response.status}`,
242
+ kind,
243
+ ...detail,
192
244
  });
193
- return null;
245
+ return kind === "terminal"
246
+ ? { kind, status: response.status, oauthError: detail.oauthError }
247
+ : {
248
+ kind,
249
+ status: response.status,
250
+ oauthError: detail.oauthError,
251
+ retryAfterMs,
252
+ };
194
253
  }
195
254
  const creds = parseOAuthResponse(await response.text(), refreshToken);
196
255
  if (!creds) {
256
+ // A 200 we cannot parse is an endpoint hiccup, not a dead token — treat
257
+ // it as transient so a retry can recover.
197
258
  log("refresh_failed", {
198
259
  source: "oauth",
199
260
  error: "no access_token in response",
261
+ kind: "transient",
200
262
  });
201
- return null;
263
+ return { kind: "transient", status: response.status };
202
264
  }
203
265
  log("refresh_success", { source: "oauth" });
204
- return creds;
266
+ return { kind: "ok", creds };
205
267
  }
206
268
  catch (err) {
269
+ // Network error / abort: transient by nature.
207
270
  log("refresh_failed", {
208
271
  source: "oauth",
209
272
  error: err instanceof Error ? err.message : String(err),
273
+ kind: "transient",
210
274
  });
211
- return null;
275
+ return { kind: "transient", status: 0 };
212
276
  }
213
277
  finally {
214
278
  clearTimeout(timer);
215
279
  }
216
280
  }
281
+ /**
282
+ * Backward-compatible wrapper: returns credentials on success, else null.
283
+ * Prefer {@link refreshViaOAuthDetailed} when the transient/terminal
284
+ * distinction matters (cooldown, CLI-fallback gating).
285
+ */
286
+ export async function refreshViaOAuth(refreshToken, timeoutMs = OAUTH_TIMEOUT_MS) {
287
+ const outcome = await refreshViaOAuthDetailed(refreshToken, timeoutMs);
288
+ return outcome.kind === "ok" ? outcome.creds : null;
289
+ }
217
290
  function refreshViaCli(configDir, requireConfigDir = false) {
218
291
  if (requireConfigDir && !configDir) {
219
292
  log("refresh_cli_skipped", {
@@ -317,6 +390,22 @@ export async function refreshIfNeeded(account, thresholdMs = 60_000) {
317
390
  const creds = target.credentials;
318
391
  if (creds.expiresAt > Date.now() + thresholdMs)
319
392
  return creds;
393
+ // If a recent refresh was rate-limited, don't re-hit the endpoint until the
394
+ // cooldown clears — adopt a sibling instance's / the CLI's fresh token if one
395
+ // has appeared, else defer. This is what stops N OpenCode instances from
396
+ // turning a single transient 429 into a sustained storm. Borrowed accounts
397
+ // are exempt: their recovery (refreshBorrowedAccount) is a distinct path.
398
+ if (!borrowedCredentialAccounts.has(target) &&
399
+ isRefreshCooldownActive(target.source)) {
400
+ const adopted = adoptFreshFromSource(target, creds.accessToken);
401
+ if (adopted)
402
+ return adopted;
403
+ log("refresh_cooldown_skip", {
404
+ source: target.source,
405
+ until: getRefreshCooldownUntil(target.source),
406
+ });
407
+ return null;
408
+ }
320
409
  // The proactive sync timer calls this directly while the request path
321
410
  // arrives via getCachedCredentials(). A rotation invalidates the refresh
322
411
  // token it was issued against, so two concurrent refreshes would leave
@@ -326,7 +415,29 @@ export async function refreshIfNeeded(account, thresholdMs = 60_000) {
326
415
  log("refresh_joined", { source: target.source });
327
416
  return inFlight;
328
417
  }
329
- const pending = performRefresh(target, creds);
418
+ // Cross-process single-flight: only one OpenCode instance / the CLI should
419
+ // hit the token endpoint at a time. If another holds the lock, wait briefly
420
+ // and adopt its result rather than piling onto an already-strained endpoint.
421
+ const lock = acquireRefreshLock(target.source);
422
+ if (!lock) {
423
+ log("refresh_lock_busy", { source: target.source });
424
+ const adopted = await waitForAdopt(target, creds.accessToken);
425
+ if (adopted)
426
+ return adopted;
427
+ // The holder produced nothing within the window (likely crashed; its lock
428
+ // ages out by TTL). Defer rather than refresh lock-free, so we don't
429
+ // recreate the burst the lock exists to prevent — the request-level wait
430
+ // loop and the lock TTL drive eventual progress.
431
+ return null;
432
+ }
433
+ const pending = (async () => {
434
+ try {
435
+ return await performRefresh(target, creds);
436
+ }
437
+ finally {
438
+ lock.release();
439
+ }
440
+ })();
330
441
  inFlightRefreshes.set(target.source, pending);
331
442
  try {
332
443
  return await pending;
@@ -335,6 +446,54 @@ export async function refreshIfNeeded(account, thresholdMs = 60_000) {
335
446
  inFlightRefreshes.delete(target.source);
336
447
  }
337
448
  }
449
+ /**
450
+ * Re-read the account's own source and adopt a token another OpenCode instance
451
+ * or the `claude` CLI has just written. Returns the adopted credentials when
452
+ * the store now holds a distinct, still-valid token, else null.
453
+ */
454
+ function adoptFreshFromSource(target, rejectedAccessToken) {
455
+ let stored = null;
456
+ try {
457
+ stored = refreshAccount(target.source, target.configDir);
458
+ }
459
+ catch {
460
+ return null;
461
+ }
462
+ if (stored &&
463
+ stored.accessToken !== rejectedAccessToken &&
464
+ stored.expiresAt > Date.now() + 60_000) {
465
+ target.credentials = stored;
466
+ borrowedCredentialAccounts.delete(target);
467
+ clearRefreshOutcome(target.source);
468
+ log("refresh_adopted_from_source", { source: target.source });
469
+ return stored;
470
+ }
471
+ return null;
472
+ }
473
+ const LOCK_ADOPT_WAIT_MS = 5_000;
474
+ const LOCK_ADOPT_POLL_MS = 250;
475
+ /**
476
+ * Another instance holds the refresh lock and is presumably refreshing. Poll
477
+ * the shared store for the token it is about to write, up to a short budget,
478
+ * before giving up.
479
+ */
480
+ async function waitForAdopt(target, rejectedAccessToken, opts = {}) {
481
+ const now = opts.now ?? Date.now;
482
+ const sleep = opts.sleep ?? ((ms) => sleepAbortable(ms));
483
+ const maxMs = opts.maxMs ?? LOCK_ADOPT_WAIT_MS;
484
+ const pollMs = opts.pollMs ?? LOCK_ADOPT_POLL_MS;
485
+ const immediate = adoptFreshFromSource(target, rejectedAccessToken);
486
+ if (immediate)
487
+ return immediate;
488
+ const deadline = now() + maxMs;
489
+ while (now() < deadline) {
490
+ await sleep(pollMs);
491
+ const adopted = adoptFreshFromSource(target, rejectedAccessToken);
492
+ if (adopted)
493
+ return adopted;
494
+ }
495
+ return null;
496
+ }
338
497
  async function performRefresh(target, creds) {
339
498
  if (borrowedCredentialAccounts.has(target)) {
340
499
  return refreshBorrowedAccount(target);
@@ -345,10 +504,12 @@ async function performRefresh(target, creds) {
345
504
  expiresIn: creds.expiresAt - Date.now(),
346
505
  });
347
506
  if (creds.refreshToken) {
348
- const oauthCreds = await refreshViaOAuth(creds.refreshToken);
349
- if (oauthCreds && oauthCreds.expiresAt > Date.now() + 60_000) {
350
- target.credentials = oauthCreds;
351
- if (!writeBackCredentials(target.source, oauthCreds, target.configDir, creds.accessToken)) {
507
+ const outcome = await refreshViaOAuthDetailed(creds.refreshToken);
508
+ if (outcome.kind === "ok" &&
509
+ outcome.creds.expiresAt > Date.now() + 60_000) {
510
+ clearRefreshOutcome(target.source);
511
+ target.credentials = outcome.creds;
512
+ if (!writeBackCredentials(target.source, outcome.creds, target.configDir, creds.accessToken)) {
352
513
  // Mirrors force_refresh_writeback_failed on the forced path. The
353
514
  // session continues from memory either way, so this stays a log
354
515
  // rather than a control-flow change: acting on the two causes
@@ -357,7 +518,48 @@ async function performRefresh(target, creds) {
357
518
  // the validated re-read — is tracked as a follow-up.
358
519
  log("refresh_writeback_failed", { source: target.source });
359
520
  }
360
- return oauthCreds;
521
+ return outcome.creds;
522
+ }
523
+ if (outcome.kind === "transient") {
524
+ // A rate-limit / 5xx / network blip: the refresh token is still valid.
525
+ // Back off so we (and our sibling OpenCode instances) stop hammering the
526
+ // endpoint, adopt a token another instance/CLI may have just written,
527
+ // and — crucially — do NOT spawn the claude CLI, which hits the same
528
+ // rate-limited endpoint and only deepens the limit.
529
+ const cooldownMs = noteRefreshTransient(target.source, {
530
+ retryAfterMs: outcome.retryAfterMs,
531
+ });
532
+ log("refresh_transient", {
533
+ source: target.source,
534
+ status: outcome.status,
535
+ oauthError: outcome.oauthError,
536
+ cooldownMs,
537
+ });
538
+ const adopted = adoptFreshFromSource(target, creds.accessToken);
539
+ if (adopted)
540
+ return adopted;
541
+ // Keep serving still-usable credentials on the proactive path.
542
+ if (creds.expiresAt > Date.now() + CLI_FALLBACK_THRESHOLD_MS)
543
+ return creds;
544
+ // Borrow a sibling account's still-valid token rather than spawning the
545
+ // claude CLI, which hits the same rate-limited endpoint.
546
+ const borrowed = tryFallbackAccount(target.source);
547
+ if (borrowed) {
548
+ target.credentials = borrowed;
549
+ borrowedCredentialAccounts.add(target);
550
+ return borrowed;
551
+ }
552
+ return null;
553
+ }
554
+ if (outcome.kind === "terminal") {
555
+ // The refresh token itself is dead (invalid_grant, ...). Fall through to
556
+ // the CLI fallback / borrowed-account recovery below.
557
+ noteRefreshTerminal(target.source);
558
+ log("refresh_terminal", {
559
+ source: target.source,
560
+ status: outcome.status,
561
+ oauthError: outcome.oauthError,
562
+ });
361
563
  }
362
564
  }
363
565
  // The claude CLI only rotates a token that is itself close to expiry, so
@@ -650,6 +852,85 @@ export async function getCachedCredentials() {
650
852
  accountCacheMap.set(account.source, { creds: fresh, cachedAt: Date.now() });
651
853
  return fresh;
652
854
  }
855
+ /** Max time a single request will wait through a transient refresh rate-limit. */
856
+ const REFRESH_WAIT_MS = (() => {
857
+ const raw = process.env.OPENCODE_CLAUDE_AUTH_REFRESH_WAIT_MS;
858
+ const parsed = raw ? Number.parseInt(raw, 10) : NaN;
859
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 45_000;
860
+ })();
861
+ const REFRESH_POLL_MS = 2_500;
862
+ function sleepAbortable(ms, signal) {
863
+ return new Promise((resolve) => {
864
+ if (signal?.aborted) {
865
+ resolve();
866
+ return;
867
+ }
868
+ const done = () => {
869
+ clearTimeout(timer);
870
+ signal?.removeEventListener("abort", done);
871
+ resolve();
872
+ };
873
+ const timer = setTimeout(done, ms);
874
+ signal?.addEventListener("abort", done, { once: true });
875
+ });
876
+ }
877
+ /**
878
+ * Resolve credentials, waiting through a transient refresh rate-limit rather
879
+ * than failing hard. Returns as soon as a token is available — ours refreshed
880
+ * once the cooldown clears, or a sibling OpenCode instance / the `claude` CLI
881
+ * wrote a fresh one to the shared store. Returns null promptly on a terminal
882
+ * failure (dead refresh token) or when the wait budget is exhausted, so the
883
+ * caller can decide between a retryable response and a hard error.
884
+ */
885
+ export async function getCredentialsWithBackoff(opts = {}) {
886
+ const first = await getCachedCredentials();
887
+ if (first)
888
+ return first;
889
+ const source = getActiveAccount()?.source;
890
+ // No active account means no in-progress refresh could ever produce a token,
891
+ // so waiting is pointless — fail fast instead of spinning the wait budget.
892
+ if (!source)
893
+ return null;
894
+ // A dead refresh token will not fix itself by waiting.
895
+ if (getRefreshFailureKind(source) === "terminal")
896
+ return null;
897
+ const now = opts.now ?? Date.now;
898
+ const sleep = opts.sleep ?? sleepAbortable;
899
+ const rng = opts.rng ?? Math.random;
900
+ const maxWaitMs = opts.maxWaitMs ?? REFRESH_WAIT_MS;
901
+ const pollMs = opts.pollMs ?? REFRESH_POLL_MS;
902
+ const deadline = now() + maxWaitMs;
903
+ log("fetch_credentials_wait", { source: source ?? null, maxWaitMs });
904
+ while (now() < deadline) {
905
+ if (opts.signal?.aborted)
906
+ return null;
907
+ // Jittered poll so sibling instances desynchronize their re-reads.
908
+ await sleep(Math.round(pollMs * (0.5 + rng() * 0.5)), opts.signal);
909
+ if (opts.signal?.aborted)
910
+ return null;
911
+ const creds = await getCachedCredentials();
912
+ if (creds)
913
+ return creds;
914
+ if (source && getRefreshFailureKind(source) === "terminal")
915
+ return null;
916
+ }
917
+ return null;
918
+ }
919
+ /**
920
+ * Whether the active account's most recent refresh failure was transient
921
+ * (rate-limited/retryable) or terminal (dead refresh token), for callers
922
+ * deciding between a retryable response and a hard "re-authenticate" error.
923
+ * An active cooldown implies a transient failure.
924
+ */
925
+ export function getActiveRefreshFailureKind() {
926
+ const source = getActiveAccount()?.source;
927
+ if (!source)
928
+ return null;
929
+ const kind = getRefreshFailureKind(source);
930
+ if (kind === "transient" || isRefreshCooldownActive(source))
931
+ return "transient";
932
+ return kind;
933
+ }
653
934
  export function reloadCredentialsFromSource() {
654
935
  const account = getActiveAccount();
655
936
  if (!account)