opencode-claude-auth 2.1.4 → 2.1.6

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.
@@ -1,12 +1,23 @@
1
- import { execFileSync, execSync } from "node:child_process";
1
+ import { execSync } from "node:child_process";
2
2
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
3
3
  import { homedir, tmpdir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import { PRIMARY_SERVICE, readAllClaudeAccounts, refreshAccount, writeBackCredentials, } from "./keychain.js";
6
6
  import { resetExcludedBetas } from "./betas.js";
7
+ import { fetchWithRetry } from "./http.js";
7
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";
8
11
  const CREDENTIAL_CACHE_TTL_MS = 30_000;
12
+ // Only inside this window will the claude CLI actually rotate a token, so
13
+ // it is also the only window where spawning it is worth a real API request.
14
+ const CLI_FALLBACK_THRESHOLD_MS = 60_000;
9
15
  const accountCacheMap = new Map();
16
+ const inFlightRefreshes = new Map();
17
+ // Accounts currently running on credentials borrowed from another account.
18
+ // Those tokens belong to the lender: they must never be used as this
19
+ // account's refresh source, and never written back to its store.
20
+ const borrowedCredentialAccounts = new WeakSet();
10
21
  let activeAccountSource = null;
11
22
  let allAccounts = [];
12
23
  export function initAccounts(accounts) {
@@ -137,60 +148,145 @@ export function parseOAuthResponse(raw, currentRefreshToken, now = Date.now()) {
137
148
  }
138
149
  if (!data.access_token)
139
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);
140
158
  return {
141
159
  accessToken: data.access_token,
142
160
  refreshToken: data.refresh_token ?? currentRefreshToken,
143
- expiresAt: Math.trunc(now + (data.expires_in ?? 36_000) * 1000),
161
+ expiresAt,
144
162
  };
145
163
  }
146
- export function refreshViaOAuth(refreshToken) {
147
- const script = `
148
- process.stdin.resume();
149
- let input = '';
150
- process.stdin.on('data', c => input += c);
151
- process.stdin.on('end', () => {
152
- const body = new URLSearchParams({
153
- grant_type: 'refresh_token',
154
- client_id: '${OAUTH_CLIENT_ID}',
155
- refresh_token: input.trim()
156
- });
157
- fetch('${OAUTH_TOKEN_URL}', {
158
- method: 'POST',
159
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
160
- body: body.toString()
161
- })
162
- .then(r => { if (!r.ok) throw new Error(String(r.status)); return r.json(); })
163
- .then(d => { process.stdout.write(JSON.stringify(d)); })
164
- .catch(e => { process.stdout.write(JSON.stringify({ error: String(e) })); process.exit(1); });
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
+ }
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
+ }
212
+ /**
213
+ * Exchange a refresh token for fresh credentials and classify the result.
214
+ * See {@link RefreshOutcome}. Uses the runtime's own fetch (no subprocess).
215
+ */
216
+ export async function refreshViaOAuthDetailed(refreshToken, timeoutMs = OAUTH_TIMEOUT_MS) {
217
+ const body = new URLSearchParams({
218
+ grant_type: "refresh_token",
219
+ client_id: OAUTH_CLIENT_ID,
220
+ refresh_token: refreshToken,
165
221
  });
166
- `;
222
+ const controller = new AbortController();
223
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
167
224
  try {
168
225
  log("refresh_started", { source: "oauth" });
169
- const result = execFileSync(process.execPath, ["-e", script], {
170
- input: refreshToken,
171
- timeout: 15_000,
172
- encoding: "utf-8",
173
- stdio: ["pipe", "pipe", "ignore"],
226
+ const response = await fetchWithRetry(OAUTH_TOKEN_URL, {
227
+ method: "POST",
228
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
229
+ body: body.toString(),
230
+ signal: controller.signal,
174
231
  });
175
- const creds = parseOAuthResponse(result, refreshToken);
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"));
239
+ log("refresh_failed", {
240
+ source: "oauth",
241
+ error: `HTTP ${response.status}`,
242
+ kind,
243
+ ...detail,
244
+ });
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
+ };
253
+ }
254
+ const creds = parseOAuthResponse(await response.text(), refreshToken);
176
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.
177
258
  log("refresh_failed", {
178
259
  source: "oauth",
179
260
  error: "no access_token in response",
261
+ kind: "transient",
180
262
  });
181
- return null;
263
+ return { kind: "transient", status: response.status };
182
264
  }
183
265
  log("refresh_success", { source: "oauth" });
184
- return creds;
266
+ return { kind: "ok", creds };
185
267
  }
186
268
  catch (err) {
269
+ // Network error / abort: transient by nature.
187
270
  log("refresh_failed", {
188
271
  source: "oauth",
189
272
  error: err instanceof Error ? err.message : String(err),
273
+ kind: "transient",
190
274
  });
191
- return null;
275
+ return { kind: "transient", status: 0 };
276
+ }
277
+ finally {
278
+ clearTimeout(timer);
192
279
  }
193
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
+ }
194
290
  function refreshViaCli(configDir, requireConfigDir = false) {
195
291
  if (requireConfigDir && !configDir) {
196
292
  log("refresh_cli_skipped", {
@@ -238,34 +334,273 @@ function refreshViaCli(configDir, requireConfigDir = false) {
238
334
  * of threshold, so this always operates on the currently active account
239
335
  * unless one is explicitly passed in.
240
336
  */
241
- export function refreshIfNeeded(account, thresholdMs = 60_000) {
337
+ export async function refreshIfNeeded(account, thresholdMs = 60_000) {
242
338
  const target = account ?? getActiveAccount();
243
339
  if (!target)
244
340
  return null;
245
- // Pick up external updates to .credentials.json (e.g. switch_claude_account
246
- // on Windows). Bounded by getCachedCredentials's 30s TTL: fires at most
247
- // ~2x/min under load. macOS keychain sources stay on the in-memory path;
248
- // their state is mutated only by our own writeBackCredentials, so no
249
- // external-update vector exists for them.
250
- if (target.source === "file") {
251
- const onDisk = refreshAccount(target.source);
252
- if (onDisk)
253
- target.credentials = onDisk;
341
+ // Pick up credentials replaced externally cswap switching accounts, the
342
+ // claude CLI in another terminal, or a second OpenCode instance. This was
343
+ // once limited to file sources, on the false assumption that a keychain
344
+ // entry is only ever mutated by our own writeBackCredentials. Bounded by
345
+ // getCachedCredentials's 30s TTL, so it fires at most ~2x/min under load.
346
+ //
347
+ // A keychain read shells out to `security`, which throws when the keychain
348
+ // is locked, access is denied, or the call times out. Degrade to the
349
+ // in-memory credentials rather than take down the request path.
350
+ //
351
+ // Adopt a usable stored blob always; an unusable one only when what we
352
+ // already hold is unusable too. Do not simplify this to an unconditional
353
+ // adopt: performRefresh ignores writeBackCredentials's return value, and
354
+ // that write can fail while the read before it succeeded (malformed blob,
355
+ // or an ACL allowing read but not add-generic-password), leaving memory
356
+ // freshly refreshed and the store holding the orphaned pre-refresh blob.
357
+ // On the reactive path that blob has under 60s left — that window is the
358
+ // only reason we refreshed — so adopting it re-enters performRefresh with
359
+ // a refresh token our own refresh just rotated dead: OAuth fails and we
360
+ // fall through to two 60s claude spawns, on every cache miss, forever.
361
+ //
362
+ // Two accepted residuals. An external switch installing an already-expired
363
+ // token while ours is usable is ignored until ours expires; cswap freshens
364
+ // a target before activating it, so that is rare. And the proactive timer
365
+ // refreshes an hour ahead (index.ts), where a failed write-back orphans a
366
+ // blob that is still usable — so it IS adopted, costing wasted background
367
+ // refreshes rather than failed requests until it drops under 60s and the
368
+ // CLI fallback recovers. No guard here closes that one: the re-read cannot
369
+ // tell "stale because our write failed" from "changed because cswap
370
+ // switched", as both present as store-disagrees-with-memory-and-usable.
371
+ // Only the return value performRefresh discards carries the distinction.
372
+ try {
373
+ const stored = refreshAccount(target.source, target.configDir);
374
+ const now = Date.now();
375
+ if (stored &&
376
+ (stored.expiresAt > now + 60_000 ||
377
+ target.credentials.expiresAt <= now + 60_000)) {
378
+ target.credentials = stored;
379
+ // Read from this account's own source, so what it returned is this
380
+ // account's own credentials — it is no longer running on a lender's.
381
+ borrowedCredentialAccounts.delete(target);
382
+ }
383
+ }
384
+ catch (err) {
385
+ log("source_reread_failed", {
386
+ source: target.source,
387
+ error: err instanceof Error ? err.message : String(err),
388
+ });
254
389
  }
255
390
  const creds = target.credentials;
256
391
  if (creds.expiresAt > Date.now() + thresholdMs)
257
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
+ }
409
+ // The proactive sync timer calls this directly while the request path
410
+ // arrives via getCachedCredentials(). A rotation invalidates the refresh
411
+ // token it was issued against, so two concurrent refreshes would leave
412
+ // one caller holding an already-dead token. Share one attempt instead.
413
+ const inFlight = inFlightRefreshes.get(target.source);
414
+ if (inFlight) {
415
+ log("refresh_joined", { source: target.source });
416
+ return inFlight;
417
+ }
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
+ })();
441
+ inFlightRefreshes.set(target.source, pending);
442
+ try {
443
+ return await pending;
444
+ }
445
+ finally {
446
+ inFlightRefreshes.delete(target.source);
447
+ }
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
+ }
497
+ async function performRefresh(target, creds) {
498
+ if (borrowedCredentialAccounts.has(target)) {
499
+ return refreshBorrowedAccount(target);
500
+ }
258
501
  log("refresh_needed", {
259
502
  source: target.source,
260
503
  expiresAt: creds.expiresAt,
261
504
  expiresIn: creds.expiresAt - Date.now(),
262
505
  });
263
506
  if (creds.refreshToken) {
264
- const oauthCreds = refreshViaOAuth(creds.refreshToken);
265
- if (oauthCreds && oauthCreds.expiresAt > Date.now() + 60_000) {
266
- target.credentials = oauthCreds;
267
- writeBackCredentials(target.source, oauthCreds, target.configDir);
268
- return oauthCreds;
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)) {
513
+ // Mirrors force_refresh_writeback_failed on the forced path. The
514
+ // session continues from memory either way, so this stays a log
515
+ // rather than a control-flow change: acting on the two causes
516
+ // (I/O failure vs. CAS mismatch) differs, and the proactive-path
517
+ // consequence — a still-usable orphaned blob being re-adopted by
518
+ // the validated re-read — is tracked as a follow-up.
519
+ log("refresh_writeback_failed", { source: target.source });
520
+ }
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
+ });
563
+ }
564
+ }
565
+ // The claude CLI only rotates a token that is itself close to expiry, so
566
+ // running it while the current one is still usable spawns a real API
567
+ // request that hands back the same token. Callers using a proactive
568
+ // threshold (the sync timer passes an hour) would otherwise pay for that
569
+ // request on every tick. Keep the fallback scoped to the reactive window
570
+ // and let the caller try again later.
571
+ if (creds.expiresAt > Date.now() + CLI_FALLBACK_THRESHOLD_MS) {
572
+ log("refresh_cli_skipped", {
573
+ source: target.source,
574
+ reason: "credentials still usable",
575
+ expiresIn: creds.expiresAt - Date.now(),
576
+ });
577
+ return creds;
578
+ }
579
+ // Every OpenCode instance refreshes independently, and a rotation
580
+ // invalidates the refresh token the others are holding. When ours is
581
+ // rejected, the instance that won may already have written usable
582
+ // credentials to the shared store during the OAuth round trip — far
583
+ // cheaper to re-read than to spawn the CLI.
584
+ //
585
+ // The file-source exclusion below is a leftover from when refreshIfNeeded
586
+ // re-read file sources only. That rationale is gone and the exclusion now
587
+ // has none: a sibling process can write a file source mid-round-trip
588
+ // exactly as it can a keychain entry. Left in place only to keep this
589
+ // change off the file path; removing it is tracked as a follow-up.
590
+ if (target.source !== "file") {
591
+ let stored = null;
592
+ try {
593
+ stored = refreshAccount(target.source, target.configDir);
594
+ }
595
+ catch {
596
+ stored = null;
597
+ }
598
+ if (stored &&
599
+ stored.accessToken !== creds.accessToken &&
600
+ stored.expiresAt > Date.now() + 60_000) {
601
+ target.credentials = stored;
602
+ log("refresh_adopted_external", { source: target.source });
603
+ return stored;
269
604
  }
270
605
  }
271
606
  log("refresh_fallback_cli", { source: target.source });
@@ -276,6 +611,7 @@ export function refreshIfNeeded(account, thresholdMs = 60_000) {
276
611
  const fallback = tryFallbackAccount(target.source);
277
612
  if (fallback) {
278
613
  target.credentials = fallback;
614
+ borrowedCredentialAccounts.add(target);
279
615
  return fallback;
280
616
  }
281
617
  log("refresh_exhausted", {
@@ -304,6 +640,62 @@ export function refreshIfNeeded(account, thresholdMs = 60_000) {
304
640
  });
305
641
  return null;
306
642
  }
643
+ /**
644
+ * Refresh path for an account running on borrowed credentials. The tokens it
645
+ * currently holds belong to another account, so they cannot be exchanged at
646
+ * the OAuth endpoint on this account's behalf, and the result must never be
647
+ * written to this account's store. Re-read our own source first — the claude
648
+ * CLI or another process may have repaired it — and otherwise borrow again.
649
+ */
650
+ async function refreshBorrowedAccount(target) {
651
+ log("refresh_borrowed", { source: target.source });
652
+ let own = null;
653
+ try {
654
+ own = refreshAccount(target.source, target.configDir);
655
+ }
656
+ catch {
657
+ own = null;
658
+ }
659
+ if (own && own.expiresAt > Date.now() + 60_000) {
660
+ borrowedCredentialAccounts.delete(target);
661
+ target.credentials = own;
662
+ log("refresh_borrowed_recovered", { source: target.source, via: "source" });
663
+ return own;
664
+ }
665
+ // A refresh token outlives its access token by weeks, so this account's
666
+ // own stored token is likely still exchangeable even though the access
667
+ // token it came with has expired. This is the only token we may present
668
+ // on its behalf, and the only result we may write to its store.
669
+ if (own?.refreshToken) {
670
+ const oauthCreds = await refreshViaOAuth(own.refreshToken);
671
+ if (oauthCreds && oauthCreds.expiresAt > Date.now() + 60_000) {
672
+ borrowedCredentialAccounts.delete(target);
673
+ target.credentials = oauthCreds;
674
+ writeBackCredentials(target.source, oauthCreds, target.configDir, own.accessToken);
675
+ log("refresh_borrowed_recovered", { source: target.source, via: "oauth" });
676
+ return oauthCreds;
677
+ }
678
+ }
679
+ const again = tryFallbackAccount(target.source);
680
+ if (again) {
681
+ target.credentials = again;
682
+ return again;
683
+ }
684
+ // Recovery failed. The account must not be left holding the lender's
685
+ // tokens, or the next cycle would take the normal path and exchange them.
686
+ // Restore its own credentials if we managed to read them — expired, but
687
+ // ours to refresh — and otherwise keep the guard in place.
688
+ if (own) {
689
+ borrowedCredentialAccounts.delete(target);
690
+ target.credentials = own;
691
+ }
692
+ log("refresh_exhausted", {
693
+ source: target.source,
694
+ hadCredentials: !!own,
695
+ expiresAt: own?.expiresAt,
696
+ });
697
+ return null;
698
+ }
307
699
  function tryFallbackAccount(excludeSource) {
308
700
  const now = Date.now();
309
701
  const candidates = allAccounts.filter((a) => a.source !== excludeSource);
@@ -353,16 +745,22 @@ export function getCredentialsForSync() {
353
745
  }
354
746
  /**
355
747
  * Re-read only the active account's credentials from its source (single
356
- * keychain service read or credentials file) and update them in place.
357
- * Used on 401 so an externally refreshed token is picked up without a
358
- * full multi-account keychain rescan.
748
+ * keychain service read or credentials file) and update them in place,
749
+ * so an externally refreshed token is picked up without a full
750
+ * multi-account keychain rescan.
751
+ *
752
+ * Currently has no call sites: the 401 path uses
753
+ * reloadCredentialsFromSource, which additionally validates the result
754
+ * and refreshes the cache. Wiring this up or deleting it is tracked as a
755
+ * follow-up; until then it must stay consistent with the read paths that
756
+ * are live, hence the configDir below.
359
757
  */
360
758
  export function reloadActiveAccount() {
361
759
  const account = getActiveAccount();
362
760
  if (!account)
363
761
  return;
364
762
  try {
365
- const fresh = refreshAccount(account.source);
763
+ const fresh = refreshAccount(account.source, account.configDir);
366
764
  if (fresh)
367
765
  account.credentials = fresh;
368
766
  }
@@ -380,16 +778,28 @@ export function reloadActiveAccount() {
380
778
  * On success the account, its source, and the cache are all updated.
381
779
  * The refresh function is injectable for tests.
382
780
  */
383
- export function forceRefreshActiveAccount(refresh = refreshViaOAuth) {
781
+ export async function forceRefreshActiveAccount(refresh = refreshViaOAuth) {
384
782
  const account = getActiveAccount();
385
783
  if (!account?.credentials.refreshToken)
386
784
  return null;
387
- const oauthCreds = refresh(account.credentials.refreshToken);
785
+ // These tokens belong to another account: exchanging them here would
786
+ // rotate the lender's refresh token and persist the result to this
787
+ // account's store. Borrowed-account recovery belongs to refreshIfNeeded.
788
+ if (borrowedCredentialAccounts.has(account)) {
789
+ log("force_refresh_skipped_borrowed", { source: account.source });
790
+ return null;
791
+ }
792
+ const priorAccessToken = account.credentials.accessToken;
793
+ const oauthCreds = await refresh(account.credentials.refreshToken);
388
794
  if (oauthCreds && oauthCreds.expiresAt > Date.now() + 60_000) {
389
795
  account.credentials = oauthCreds;
390
- if (!writeBackCredentials(account.source, oauthCreds)) {
391
- // Session continues from memory/cache; a later source re-read may
392
- // resurrect the rejected token and trigger another refresh.
796
+ if (!writeBackCredentials(account.source, oauthCreds, account.configDir, priorAccessToken)) {
797
+ // Session continues from memory/cache either way, but the two causes
798
+ // diverge on a later source re-read. An I/O failure leaves our own
799
+ // rejected token in the store, so the re-read resurrects it and
800
+ // triggers another refresh. A CAS mismatch means the store now holds
801
+ // another account's token, so the re-read adopts that instead and this
802
+ // account stops using the credentials it just refreshed.
393
803
  log("force_refresh_writeback_failed", { source: account.source });
394
804
  }
395
805
  accountCacheMap.set(account.source, {
@@ -414,7 +824,7 @@ export function invalidateCredentialCache() {
414
824
  log("cache_invalidated", { source: account.source });
415
825
  }
416
826
  }
417
- export function getCachedCredentials() {
827
+ export async function getCachedCredentials() {
418
828
  const account = getActiveAccount();
419
829
  if (!account)
420
830
  return null;
@@ -433,22 +843,103 @@ export function getCachedCredentials() {
433
843
  source: account.source,
434
844
  reason: cached ? "stale or expiring" : "empty",
435
845
  });
436
- const fresh = refreshIfNeeded(account);
846
+ const fresh = await refreshIfNeeded(account);
437
847
  if (!fresh) {
438
848
  log("credentials_unavailable", { source: account.source });
439
849
  accountCacheMap.delete(account.source);
440
850
  return null;
441
851
  }
442
- accountCacheMap.set(account.source, { creds: fresh, cachedAt: now });
852
+ accountCacheMap.set(account.source, { creds: fresh, cachedAt: Date.now() });
443
853
  return fresh;
444
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
+ }
445
934
  export function reloadCredentialsFromSource() {
446
935
  const account = getActiveAccount();
447
936
  if (!account)
448
937
  return null;
449
938
  let reloaded;
450
939
  try {
451
- reloaded = refreshAccount(account.source);
940
+ // Same configDir the write path resolves, so the compare-and-swap in
941
+ // writeBackCredentials compares against the file this read came from.
942
+ reloaded = refreshAccount(account.source, account.configDir);
452
943
  }
453
944
  catch {
454
945
  accountCacheMap.delete(account.source);
@@ -476,6 +967,13 @@ export function reloadCredentialsFromSource() {
476
967
  return null;
477
968
  }
478
969
  account.credentials = reloaded;
970
+ // Read from this account's own source, so what it returned is this
971
+ // account's own credentials — it is no longer running on a lender's.
972
+ // Same invariant as refreshIfNeeded's up-front re-read: leaving the flag
973
+ // set here makes forceRefreshActiveAccount decline to exchange a token
974
+ // that is legitimately this account's, which strands the 401 recovery
975
+ // loop's second attempt on a credential it could have refreshed.
976
+ borrowedCredentialAccounts.delete(account);
479
977
  accountCacheMap.set(account.source, { creds: reloaded, cachedAt: now });
480
978
  log("credentials_source_reload", {
481
979
  source: account.source,