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.
- package/README.md +20 -10
- package/dist/credentials.d.ts +90 -7
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +557 -59
- package/dist/credentials.js.map +1 -1
- package/dist/http.d.ts +3 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +74 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +183 -68
- package/dist/index.js.map +1 -1
- package/dist/keychain.d.ts +20 -1
- package/dist/keychain.d.ts.map +1 -1
- package/dist/keychain.js +66 -1
- package/dist/keychain.js.map +1 -1
- package/dist/refresh-backoff.d.ts +49 -0
- package/dist/refresh-backoff.d.ts.map +1 -0
- package/dist/refresh-backoff.js +99 -0
- package/dist/refresh-backoff.js.map +1 -0
- package/dist/refresh-lock.d.ts +22 -0
- package/dist/refresh-lock.d.ts.map +1 -0
- package/dist/refresh-lock.js +117 -0
- package/dist/refresh-lock.js.map +1 -0
- package/dist/transforms.d.ts +41 -1
- package/dist/transforms.d.ts.map +1 -1
- package/dist/transforms.js +238 -46
- package/dist/transforms.js.map +1 -1
- package/package.json +1 -1
package/dist/credentials.js
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
|
-
import {
|
|
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
|
|
161
|
+
expiresAt,
|
|
144
162
|
};
|
|
145
163
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
265
|
-
if (
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
*
|
|
358
|
-
*
|
|
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
|
-
|
|
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
|
|
392
|
-
//
|
|
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
|
-
|
|
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,
|