omnirush 0.8.5 → 0.8.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/assets/collect-once.ts +6 -5
- package/assets/extensions/omnirush/agents-lib.ts +66 -3
- package/assets/extensions/omnirush/agents.ts +62 -14
- package/assets/extensions/omnirush/auth.js +399 -115
- package/assets/extensions/omnirush/collector.ts +31 -5
- package/assets/extensions/omnirush/commands.ts +12 -15
- package/assets/extensions/omnirush/sota.ts +52 -43
- package/package.json +2 -2
- package/src/bin.js +48 -21
|
@@ -17,7 +17,11 @@
|
|
|
17
17
|
// -> 400 {"detail":"expired_token"} (unknown/expired)
|
|
18
18
|
// POST /device/refresh {refresh_token}
|
|
19
19
|
// -> 200 {access_token, refresh_token, gateway_url?} (rotate both)
|
|
20
|
-
// -> 401 {"detail":"refresh_token_invalid_or_expired"}
|
|
20
|
+
// -> 401 {"detail":"refresh_token_invalid_or_expired"} (not the
|
|
21
|
+
// device's current refresh token — superseded more than the
|
|
22
|
+
// rotation grace ago — or the device was revoked/expired)
|
|
23
|
+
// -> 409 {"detail":"refresh_token_already_used"} (race)
|
|
24
|
+
// -> 403 {"detail":"account_inactive|account_pending|account_rejected"}
|
|
21
25
|
// GET /device/me -> 200 device identity | 401 device_token_invalid
|
|
22
26
|
// POST /collect -> zstd body, X-Omnirush-Session-ID header
|
|
23
27
|
//
|
|
@@ -78,14 +82,36 @@ function ensureDir(dir) {
|
|
|
78
82
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
79
83
|
}
|
|
80
84
|
|
|
81
|
-
|
|
85
|
+
/** Synchronous sleep (tiny waits between file-system retries only). */
|
|
86
|
+
function sleepSync(ms) {
|
|
82
87
|
try {
|
|
83
|
-
|
|
88
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
84
89
|
} catch {
|
|
85
|
-
|
|
90
|
+
/* no Atomics.wait (never on node/bun): skip the pause */
|
|
86
91
|
}
|
|
87
92
|
}
|
|
88
93
|
|
|
94
|
+
function readJsonFile(filePath) {
|
|
95
|
+
// An older CLI (before atomic writes) truncates then writes, so a
|
|
96
|
+
// reader can catch the file empty or half-written for a moment: a
|
|
97
|
+
// parse failure on an existing file is retried briefly before it
|
|
98
|
+
// counts as "no credentials".
|
|
99
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
100
|
+
let text;
|
|
101
|
+
try {
|
|
102
|
+
text = fs.readFileSync(filePath, "utf8");
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(text);
|
|
108
|
+
} catch {
|
|
109
|
+
if (attempt < 3) sleepSync(15 * (attempt + 1));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
89
115
|
/**
|
|
90
116
|
* Enforce 0600 where POSIX mode bits exist (repairing files that were
|
|
91
117
|
* written too openly). On Windows chmod is a permissions no-op — it only
|
|
@@ -101,10 +127,51 @@ function repairPosixMode(filePath) {
|
|
|
101
127
|
}
|
|
102
128
|
}
|
|
103
129
|
|
|
130
|
+
const WINDOWS_BUSY = new Set(["EPERM", "EACCES", "EBUSY"]);
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Atomic replace: write a private temp file next to the target, flush it,
|
|
134
|
+
* rename it over the target. Readers in other processes see either the
|
|
135
|
+
* old or the new file, never a truncated one. On Windows a rename over a
|
|
136
|
+
* file another process has open can fail transiently (EPERM/EACCES/
|
|
137
|
+
* EBUSY) — retried with a short backoff.
|
|
138
|
+
*/
|
|
139
|
+
export function writeFileAtomic(filePath, data) {
|
|
140
|
+
const tmp = `${filePath}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
|
|
141
|
+
const fd = fs.openSync(tmp, "wx", 0o600);
|
|
142
|
+
try {
|
|
143
|
+
fs.writeSync(fd, data);
|
|
144
|
+
try {
|
|
145
|
+
fs.fsyncSync(fd);
|
|
146
|
+
} catch {
|
|
147
|
+
/* not every filesystem supports fsync */
|
|
148
|
+
}
|
|
149
|
+
} finally {
|
|
150
|
+
fs.closeSync(fd);
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
for (let attempt = 0; ; attempt++) {
|
|
154
|
+
try {
|
|
155
|
+
fs.renameSync(tmp, filePath);
|
|
156
|
+
return;
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (attempt >= 9 || !WINDOWS_BUSY.has(error?.code)) throw error;
|
|
159
|
+
sleepSync(20 * (attempt + 1));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
} finally {
|
|
163
|
+
try {
|
|
164
|
+
fs.rmSync(tmp, { force: true });
|
|
165
|
+
} catch {
|
|
166
|
+
/* already renamed away */
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
104
171
|
function writeJsonFile0600(dir, name, value) {
|
|
105
172
|
ensureDir(dir);
|
|
106
173
|
const filePath = path.join(dir, name);
|
|
107
|
-
|
|
174
|
+
writeFileAtomic(filePath, JSON.stringify(value, null, 2) + "\n");
|
|
108
175
|
repairPosixMode(filePath);
|
|
109
176
|
}
|
|
110
177
|
|
|
@@ -301,79 +368,177 @@ export async function pollForToken(origin, {
|
|
|
301
368
|
}
|
|
302
369
|
}
|
|
303
370
|
|
|
304
|
-
// --- cross-process refresh coordination
|
|
371
|
+
// --- cross-process refresh coordination ------------------------------------
|
|
305
372
|
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
|
|
373
|
+
// Every omnirush process on a machine — each terminal, and every
|
|
374
|
+
// spawn_agents sub-agent (a separate process) — shares
|
|
375
|
+
// <OMNIRUSH_DIR>/auth.json. The manager rotates BOTH tokens on every
|
|
376
|
+
// refresh and remembers only the one refresh token a rotation just
|
|
377
|
+
// replaced, for a short grace window (120 s on the live backend), so two
|
|
378
|
+
// processes refreshing with the same pair minutes apart would leave the
|
|
379
|
+
// second one holding a dead refresh token although the device session is
|
|
380
|
+
// fine. The rules:
|
|
381
|
+
// - auth.json is the only source of truth: every request re-reads it
|
|
382
|
+
// (a token held in memory or in the environment goes stale the
|
|
383
|
+
// moment another process rotates);
|
|
384
|
+
// - a 401 first adopts a newer on-disk pair; only when the disk still
|
|
385
|
+
// holds the token that failed does anyone call /device/refresh, and
|
|
386
|
+
// then only under an exclusive lock, re-reading after acquiring it;
|
|
387
|
+
// - writes are atomic (temp file + rename), so no reader ever sees a
|
|
388
|
+
// half-written pair;
|
|
389
|
+
// - "session no longer valid" is reported only when the refresh token
|
|
390
|
+
// that is on disk RIGHT NOW (re-read under the lock) is rejected.
|
|
391
|
+
|
|
392
|
+
// Same name as earlier releases, so old and new CLIs running side by
|
|
393
|
+
// side still serialize against each other.
|
|
315
394
|
const REFRESH_LOCK_FILE = ".refresh-lock";
|
|
316
395
|
const REFRESH_LOCK_STALE_MS = 30_000;
|
|
317
|
-
const REFRESH_LOCK_WAIT_MS =
|
|
318
|
-
const REFRESH_LOCK_POLL_MS =
|
|
396
|
+
const REFRESH_LOCK_WAIT_MS = 45_000;
|
|
397
|
+
const REFRESH_LOCK_POLL_MS = 100;
|
|
398
|
+
const REFRESH_LOCK_HEARTBEAT_MS = 5_000;
|
|
399
|
+
|
|
400
|
+
function readLockInfo(lockPath) {
|
|
401
|
+
try {
|
|
402
|
+
const value = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
|
403
|
+
return value && typeof value === "object" ? value : {};
|
|
404
|
+
} catch {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** True when `pid` is certainly gone (only checkable on this host). */
|
|
410
|
+
function processIsGone(pid) {
|
|
411
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
412
|
+
try {
|
|
413
|
+
process.kill(pid, 0);
|
|
414
|
+
return false;
|
|
415
|
+
} catch (error) {
|
|
416
|
+
return error?.code === "ESRCH";
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function lockIsStale(lockPath, info, staleMs, now) {
|
|
421
|
+
// Holder died (same host: its pid is gone) -> stale right away.
|
|
422
|
+
if (info && info.host === os.hostname() && processIsGone(info.pid)) return true;
|
|
423
|
+
// Otherwise by age: the holder refreshes its mtime every few seconds
|
|
424
|
+
// (heartbeat), so an old mtime means a hung or crashed holder.
|
|
425
|
+
try {
|
|
426
|
+
const stats = fs.statSync(lockPath);
|
|
427
|
+
return now() - stats.mtimeMs > staleMs;
|
|
428
|
+
} catch {
|
|
429
|
+
return false; // vanished — the caller just retries the create
|
|
430
|
+
}
|
|
431
|
+
}
|
|
319
432
|
|
|
320
433
|
/**
|
|
321
|
-
* Take the exclusive refresh lock (O_EXCL create
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
* rejected refresh is recoverable, a hang is not).
|
|
434
|
+
* Take the exclusive cross-process refresh lock (O_EXCL create, which
|
|
435
|
+
* works the same on Windows). Returns a handle for releaseRefreshLock, or
|
|
436
|
+
* null when it could not be had within `waitMs` (the caller then goes on
|
|
437
|
+
* unlocked — an extra refresh is recoverable, a hang is not).
|
|
326
438
|
*/
|
|
327
|
-
async function acquireRefreshLock(dir,
|
|
439
|
+
export async function acquireRefreshLock(dir, {
|
|
440
|
+
staleMs = REFRESH_LOCK_STALE_MS,
|
|
441
|
+
waitMs = REFRESH_LOCK_WAIT_MS,
|
|
442
|
+
pollMs = REFRESH_LOCK_POLL_MS,
|
|
443
|
+
heartbeatMs = REFRESH_LOCK_HEARTBEAT_MS,
|
|
444
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
445
|
+
now = () => Date.now(),
|
|
446
|
+
} = {}) {
|
|
328
447
|
const lockPath = path.join(dir, REFRESH_LOCK_FILE);
|
|
329
|
-
const
|
|
448
|
+
const nonce = `${process.pid}-${Math.random().toString(36).slice(2, 12)}`;
|
|
449
|
+
const deadline = now() + waitMs;
|
|
450
|
+
try {
|
|
451
|
+
ensureDir(dir);
|
|
452
|
+
} catch {
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
330
455
|
for (;;) {
|
|
331
456
|
try {
|
|
332
457
|
const fd = fs.openSync(lockPath, "wx", 0o600);
|
|
333
|
-
fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
|
|
334
|
-
fs.closeSync(fd);
|
|
335
|
-
return true;
|
|
336
|
-
} catch (error) {
|
|
337
|
-
if (error?.code !== "EEXIST") return false;
|
|
338
458
|
try {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
459
|
+
fs.writeSync(fd, JSON.stringify({ pid: process.pid, host: os.hostname(), nonce, at: now() }));
|
|
460
|
+
} finally {
|
|
461
|
+
fs.closeSync(fd);
|
|
462
|
+
}
|
|
463
|
+
let heartbeat = null;
|
|
464
|
+
if (heartbeatMs > 0) {
|
|
465
|
+
heartbeat = setInterval(() => {
|
|
466
|
+
try {
|
|
467
|
+
const at = new Date();
|
|
468
|
+
fs.utimesSync(lockPath, at, at);
|
|
469
|
+
} catch {
|
|
470
|
+
/* lost the file — release copes */
|
|
471
|
+
}
|
|
472
|
+
}, heartbeatMs);
|
|
473
|
+
heartbeat.unref?.();
|
|
474
|
+
}
|
|
475
|
+
return { lockPath, nonce, heartbeat };
|
|
476
|
+
} catch (error) {
|
|
477
|
+
const code = error?.code;
|
|
478
|
+
if (code === "EEXIST") {
|
|
479
|
+
const info = readLockInfo(lockPath);
|
|
480
|
+
if (lockIsStale(lockPath, info, staleMs, now)) {
|
|
481
|
+
// Remove it only if it is still the lock we judged stale.
|
|
482
|
+
const again = readLockInfo(lockPath);
|
|
483
|
+
if ((again?.nonce ?? null) === (info?.nonce ?? null)) {
|
|
484
|
+
try {
|
|
485
|
+
fs.rmSync(lockPath, { force: true });
|
|
486
|
+
} catch {
|
|
487
|
+
/* Windows: someone has it open — retry below */
|
|
488
|
+
}
|
|
489
|
+
}
|
|
342
490
|
continue;
|
|
343
491
|
}
|
|
344
|
-
}
|
|
345
|
-
|
|
492
|
+
} else if (!WINDOWS_BUSY.has(code)) {
|
|
493
|
+
// Read-only or vanished state dir: no lock is possible at all.
|
|
494
|
+
return null;
|
|
346
495
|
}
|
|
347
496
|
}
|
|
348
|
-
if (
|
|
349
|
-
await sleep(
|
|
497
|
+
if (now() >= deadline) return null;
|
|
498
|
+
await sleep(pollMs + Math.floor(Math.random() * pollMs));
|
|
350
499
|
}
|
|
351
500
|
}
|
|
352
501
|
|
|
353
|
-
/** Release the refresh lock, but only if
|
|
354
|
-
function releaseRefreshLock(
|
|
355
|
-
|
|
502
|
+
/** Release the refresh lock, but only if it is still ours. */
|
|
503
|
+
export function releaseRefreshLock(handle) {
|
|
504
|
+
if (!handle) return;
|
|
505
|
+
if (handle.heartbeat) clearInterval(handle.heartbeat);
|
|
506
|
+
const info = readLockInfo(handle.lockPath);
|
|
507
|
+
if (info?.nonce !== handle.nonce) return;
|
|
356
508
|
try {
|
|
357
|
-
|
|
358
|
-
if (content.includes(String(process.pid))) fs.rmSync(lockPath, { force: true });
|
|
509
|
+
fs.rmSync(handle.lockPath, { force: true });
|
|
359
510
|
} catch {
|
|
360
|
-
/* gone already */
|
|
511
|
+
/* gone already, or Windows holds it open: the next taker ages it out */
|
|
361
512
|
}
|
|
362
513
|
}
|
|
363
514
|
|
|
515
|
+
/**
|
|
516
|
+
* The manager refused a refresh. `status` is the HTTP status and `detail`
|
|
517
|
+
* the manager's code: 401 refresh_token_invalid_or_expired (the token is
|
|
518
|
+
* not the device's current one — or the device was revoked or expired;
|
|
519
|
+
* the manager does not tell these apart), 409 refresh_token_already_used
|
|
520
|
+
* (lost a race inside the manager), 403 account_* (account not usable).
|
|
521
|
+
*/
|
|
364
522
|
export class RefreshRejectedError extends Error {
|
|
365
|
-
constructor(detail) {
|
|
523
|
+
constructor(detail, status = 0) {
|
|
366
524
|
super(`refresh rejected: ${detail}`);
|
|
367
525
|
this.name = "RefreshRejectedError";
|
|
526
|
+
this.detail = detail;
|
|
527
|
+
this.status = status;
|
|
368
528
|
}
|
|
369
529
|
}
|
|
370
530
|
|
|
371
|
-
/** Rotate both tokens. Throws RefreshRejectedError when the
|
|
531
|
+
/** Rotate both tokens. Throws RefreshRejectedError when the manager refuses. */
|
|
372
532
|
export async function refreshTokens(origin, { refreshToken, fetchImpl } = {}) {
|
|
373
533
|
const doFetch = fetchImpl ?? globalThis.fetch;
|
|
374
534
|
const { response, payload } = await postJson(`${origin}/device/refresh`, { refresh_token: refreshToken }, doFetch);
|
|
375
535
|
if (!response.ok) {
|
|
376
|
-
|
|
536
|
+
if (response.status === 401 || response.status === 403 || response.status === 409 || response.status === 400) {
|
|
537
|
+
throw new RefreshRejectedError(detailOf(payload, `HTTP ${response.status}`), response.status);
|
|
538
|
+
}
|
|
539
|
+
// 5xx that outlived the retries, or anything unexpected: not a verdict
|
|
540
|
+
// on the session.
|
|
541
|
+
throw new Error(`device refresh failed: HTTP ${response.status}`);
|
|
377
542
|
}
|
|
378
543
|
const accessToken = typeof payload?.access_token === "string" ? payload.access_token : "";
|
|
379
544
|
const nextRefreshToken = typeof payload?.refresh_token === "string" ? payload.refresh_token : "";
|
|
@@ -386,6 +551,34 @@ export async function refreshTokens(origin, { refreshToken, fetchImpl } = {}) {
|
|
|
386
551
|
return { accessToken, refreshToken: nextRefreshToken, ...(gatewayUrl ? { gatewayUrl } : {}) };
|
|
387
552
|
}
|
|
388
553
|
|
|
554
|
+
/** The line a dead session prints (and why), by recover() outcome reason. */
|
|
555
|
+
export function sessionEndedMessage(outcome) {
|
|
556
|
+
if (outcome?.reason === "account") {
|
|
557
|
+
return `Omnirush: this account cannot use Omnirush right now (${outcome.detail || "account not active"}) — see https://omnirush.ai/console`;
|
|
558
|
+
}
|
|
559
|
+
if (outcome?.reason === "signed_out") {
|
|
560
|
+
return "Omnirush: not signed in — run `omnirush login` to sign in.";
|
|
561
|
+
}
|
|
562
|
+
return "Omnirush: this device's session is no longer valid — run `omnirush login` to sign in again.";
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Environment for a child omnirush process (sub-agent): where the shared
|
|
567
|
+
* credentials live, plus the CURRENT on-disk access token as a bootstrap
|
|
568
|
+
* value only — the child re-reads auth.json for every request, so a
|
|
569
|
+
* later rotation by any process never strands it.
|
|
570
|
+
*/
|
|
571
|
+
export function childAuthEnv(env = process.env) {
|
|
572
|
+
const dir = omniDir(env);
|
|
573
|
+
const out = { OMNIRUSH_DIR: dir };
|
|
574
|
+
const auth = loadAuth(dir);
|
|
575
|
+
if (auth) {
|
|
576
|
+
out.OMNIRUSH_TOKEN = auth.accessToken;
|
|
577
|
+
out.OMNIRUSH_ACCESS_TOKEN = auth.accessToken;
|
|
578
|
+
}
|
|
579
|
+
return out;
|
|
580
|
+
}
|
|
581
|
+
|
|
389
582
|
export async function deviceMe(origin, { accessToken, fetchImpl } = {}) {
|
|
390
583
|
const doFetch = fetchImpl ?? globalThis.fetch;
|
|
391
584
|
// 401 is a protocol answer (stale access token — the caller refreshes
|
|
@@ -428,108 +621,199 @@ export function redactForDisplay(value) {
|
|
|
428
621
|
}
|
|
429
622
|
|
|
430
623
|
/**
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
624
|
+
* The process's view of the shared device credentials, and the one way
|
|
625
|
+
* to recover from a 401.
|
|
626
|
+
*
|
|
627
|
+
* accessToken() the current access token — re-read from auth.json
|
|
628
|
+
* on every call, so a rotation by any other process
|
|
629
|
+
* (another terminal, a sub-agent) is picked up by
|
|
630
|
+
* the very next request;
|
|
631
|
+
* recover(tokenUsed) after a 401 on `tokenUsed`: resolves
|
|
632
|
+
* {status:"ok", accessToken} with a token worth
|
|
633
|
+
* retrying with, {status:"dead", reason, detail}
|
|
634
|
+
* when the device session is really gone
|
|
635
|
+
* (reason "session" | "account" | "signed_out"), or
|
|
636
|
+
* {status:"transient", error} when the manager could
|
|
637
|
+
* not be asked (network, 5xx) — never a sign-out.
|
|
638
|
+
* refresh(expected) the older boolean form of recover().
|
|
639
|
+
*
|
|
640
|
+
* Callers retry with the returned token and call recover() again if that
|
|
641
|
+
* one is refused too (an adopted on-disk token may itself have expired;
|
|
642
|
+
* the second call then refreshes it). Concurrent callers in one process
|
|
643
|
+
* share one recovery; across processes the refresh lock serializes.
|
|
435
644
|
*/
|
|
436
|
-
export function createRefresher({
|
|
645
|
+
export function createRefresher({
|
|
646
|
+
dir,
|
|
647
|
+
origin,
|
|
648
|
+
fetchImpl,
|
|
649
|
+
now = () => Date.now(),
|
|
650
|
+
lock: lockOptions = {},
|
|
651
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
652
|
+
} = {}) {
|
|
437
653
|
const doFetch = fetchImpl ?? globalThis.fetch;
|
|
438
|
-
|
|
654
|
+
// In-memory copy: only the fallback when there is no state dir, or the
|
|
655
|
+
// file cannot be read at the moment.
|
|
656
|
+
let memory = dir ? loadAuth(dir) : null;
|
|
439
657
|
let inflight = null;
|
|
440
658
|
let lastPersistError = null;
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
659
|
+
// A refresh token the manager already declared dead: answer later
|
|
660
|
+
// callers from here instead of asking again with the same token.
|
|
661
|
+
let deadFor = null;
|
|
662
|
+
|
|
663
|
+
const reload = () => {
|
|
664
|
+
if (!dir) return memory;
|
|
665
|
+
const disk = loadAuth(dir);
|
|
666
|
+
if (disk) memory = disk;
|
|
667
|
+
else if (!fs.existsSync(authFilePath(dir))) memory = null; // signed out
|
|
668
|
+
return memory;
|
|
444
669
|
};
|
|
445
670
|
|
|
446
|
-
const persist = async (next
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
await
|
|
671
|
+
const persist = async (next) => {
|
|
672
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
673
|
+
try {
|
|
674
|
+
saveAuth(dir, next);
|
|
675
|
+
lastPersistError = null;
|
|
676
|
+
return;
|
|
677
|
+
} catch (error) {
|
|
678
|
+
lastPersistError = error?.message ?? "persist failed";
|
|
679
|
+
await sleep(100 * 2 ** attempt);
|
|
455
680
|
}
|
|
456
681
|
}
|
|
457
682
|
};
|
|
458
683
|
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
const
|
|
465
|
-
if (
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
// 2.
|
|
470
|
-
//
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
: false;
|
|
684
|
+
const ok = (auth) => ({ status: "ok", accessToken: auth.accessToken });
|
|
685
|
+
|
|
686
|
+
const recoverOnce = async (tokenUsed) => {
|
|
687
|
+
// 1. Someone (another process, or this one) already rotated: the disk
|
|
688
|
+
// holds a different token — use it, no network, no race.
|
|
689
|
+
const latest = reload();
|
|
690
|
+
if (!latest) return { status: "dead", reason: "signed_out", detail: "no stored credentials" };
|
|
691
|
+
if (latest.accessToken !== tokenUsed) return ok(latest);
|
|
692
|
+
if (deadFor && deadFor.refreshToken === latest.refreshToken) return deadFor.outcome;
|
|
693
|
+
|
|
694
|
+
// 2. The disk token itself failed: refresh — under the cross-process
|
|
695
|
+
// lock, re-reading once we hold it (the holder we waited for has
|
|
696
|
+
// usually just written the rotated pair).
|
|
697
|
+
const handle = dir ? await acquireRefreshLock(dir, { sleep, ...lockOptions }) : null;
|
|
474
698
|
try {
|
|
475
|
-
|
|
476
|
-
if (
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
if (error instanceof RefreshRejectedError) {
|
|
486
|
-
// 3. Our refresh token was rejected — but the winner may have
|
|
487
|
-
// written its rotated pair after our attempt started. Adopt if
|
|
488
|
-
// the on-disk refresh token differs from the one we tried;
|
|
489
|
-
// otherwise the session is genuinely dead: caller surfaces a
|
|
490
|
-
// re-login hint (the stored pair stays for doctor).
|
|
491
|
-
const latest = dir ? loadAuth(dir) : null;
|
|
492
|
-
if (latest && latest.refreshToken && latest.refreshToken !== attemptRefreshToken) {
|
|
493
|
-
setAuth(latest);
|
|
494
|
-
return true;
|
|
699
|
+
let base = reload();
|
|
700
|
+
if (!base) return { status: "dead", reason: "signed_out", detail: "no stored credentials" };
|
|
701
|
+
if (base.accessToken !== tokenUsed) return ok(base);
|
|
702
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
703
|
+
let next;
|
|
704
|
+
try {
|
|
705
|
+
next = await refreshTokens(origin, { refreshToken: base.refreshToken, fetchImpl: doFetch });
|
|
706
|
+
} catch (error) {
|
|
707
|
+
if (!(error instanceof RefreshRejectedError)) {
|
|
708
|
+
return { status: "transient", error: error?.message ?? String(error) };
|
|
495
709
|
}
|
|
496
|
-
|
|
710
|
+
// 3. Refused. If the pair on disk moved while we asked (a
|
|
711
|
+
// process that did not wait for the lock, or an older CLI),
|
|
712
|
+
// that pair is the live one.
|
|
713
|
+
const after = reload();
|
|
714
|
+
if (after && after.refreshToken !== base.refreshToken) return ok(after);
|
|
715
|
+
if (error.status === 409) {
|
|
716
|
+
// Lost a race inside the manager: ask again shortly.
|
|
717
|
+
await sleep(250 * (attempt + 1));
|
|
718
|
+
base = reload() ?? base;
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
const outcome = error.status === 403
|
|
722
|
+
? { status: "dead", reason: "account", detail: error.detail }
|
|
723
|
+
: { status: "dead", reason: "session", detail: error.detail };
|
|
724
|
+
// The stored pair stays on disk (doctor reports it); the verdict
|
|
725
|
+
// is remembered for this refresh token only.
|
|
726
|
+
deadFor = { refreshToken: base.refreshToken, outcome };
|
|
727
|
+
return outcome;
|
|
497
728
|
}
|
|
498
|
-
|
|
729
|
+
memory = next;
|
|
730
|
+
if (dir) await persist(next);
|
|
731
|
+
deadFor = null;
|
|
732
|
+
return ok(next);
|
|
499
733
|
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
734
|
+
return { status: "transient", error: "refresh kept racing" };
|
|
735
|
+
} finally {
|
|
736
|
+
releaseRefreshLock(handle);
|
|
737
|
+
}
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
const recover = async (tokenUsed) => {
|
|
741
|
+
// One recovery at a time per process; a caller arriving meanwhile
|
|
742
|
+
// re-evaluates afterwards (usually: the disk now differs -> ok).
|
|
743
|
+
while (inflight) {
|
|
744
|
+
try {
|
|
745
|
+
await inflight;
|
|
746
|
+
} catch {
|
|
747
|
+
/* its own caller handles it */
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
inflight = recoverOnce(tokenUsed);
|
|
751
|
+
try {
|
|
752
|
+
return await inflight;
|
|
503
753
|
} finally {
|
|
504
|
-
|
|
754
|
+
inflight = null;
|
|
505
755
|
}
|
|
506
756
|
};
|
|
507
757
|
|
|
508
758
|
return {
|
|
509
759
|
get auth() {
|
|
510
|
-
return
|
|
760
|
+
return reload();
|
|
511
761
|
},
|
|
512
762
|
get lastPersistError() {
|
|
513
763
|
return lastPersistError;
|
|
514
764
|
},
|
|
515
|
-
|
|
516
|
-
|
|
765
|
+
accessToken() {
|
|
766
|
+
return reload()?.accessToken ?? "";
|
|
517
767
|
},
|
|
518
|
-
|
|
519
|
-
|
|
768
|
+
setAuth(next) {
|
|
769
|
+
memory = next;
|
|
520
770
|
},
|
|
771
|
+
reload,
|
|
521
772
|
now,
|
|
773
|
+
recover,
|
|
522
774
|
async refresh(expectedAccessToken) {
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
inflight ??= performRefresh(expectedAccessToken).finally(() => {
|
|
526
|
-
inflight = null;
|
|
527
|
-
});
|
|
528
|
-
return inflight;
|
|
775
|
+
const outcome = await recover(expectedAccessToken);
|
|
776
|
+
return outcome.status === "ok";
|
|
529
777
|
},
|
|
530
778
|
};
|
|
531
779
|
}
|
|
532
780
|
|
|
781
|
+
/**
|
|
782
|
+
* Send a device-authenticated request with the CURRENT on-disk token and
|
|
783
|
+
* recover from 401s: `send(token)` performs the request. Up to
|
|
784
|
+
* `maxRecoveries` times a 401 asks the refresher for a better token and
|
|
785
|
+
* resends. Resolves {response, outcome}: `outcome` is null on success (or
|
|
786
|
+
* any non-401 answer), or the recover() verdict that ended the loop —
|
|
787
|
+
* {status:"dead", ...} means the device session itself is gone,
|
|
788
|
+
* {status:"transient", ...} that the manager could not be reached.
|
|
789
|
+
*/
|
|
790
|
+
export async function sendWithDeviceAuth(send, refresher, {
|
|
791
|
+
fallbackToken = "",
|
|
792
|
+
maxRecoveries = 3,
|
|
793
|
+
onResponse,
|
|
794
|
+
} = {}) {
|
|
795
|
+
let token = refresher.accessToken() || fallbackToken;
|
|
796
|
+
let response = await send(token);
|
|
797
|
+
onResponse?.(response);
|
|
798
|
+
for (let round = 0; response?.status === 401 && round < maxRecoveries; round++) {
|
|
799
|
+
const outcome = await refresher.recover(token);
|
|
800
|
+
if (outcome.status !== "ok") return { response, outcome };
|
|
801
|
+
try {
|
|
802
|
+
await response.body?.cancel?.();
|
|
803
|
+
} catch {
|
|
804
|
+
/* already consumed */
|
|
805
|
+
}
|
|
806
|
+
token = outcome.accessToken;
|
|
807
|
+
response = await send(token);
|
|
808
|
+
onResponse?.(response);
|
|
809
|
+
}
|
|
810
|
+
if (response?.status === 401) {
|
|
811
|
+
// A token the manager issued moments ago is refused too.
|
|
812
|
+
return { response, outcome: { status: "dead", reason: "session", detail: "access token refused after refresh" } };
|
|
813
|
+
}
|
|
814
|
+
return { response, outcome: null };
|
|
815
|
+
}
|
|
816
|
+
|
|
533
817
|
/** Best-effort browser open. NEVER throws or crashes: the spawned child's
|
|
534
818
|
* 'error' event is always handled (ENOENT on a headless Linux box would
|
|
535
819
|
* otherwise take the CLI down — Foxtran's login crash). Linux tries a
|