omnirush 0.8.5 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/assets/CHANGELOG.md +85 -0
  2. package/assets/collect-once.ts +6 -5
  3. package/assets/extensions/omnirush/agents-lib.ts +196 -12
  4. package/assets/extensions/omnirush/agents.ts +97 -105
  5. package/assets/extensions/omnirush/auth.js +399 -115
  6. package/assets/extensions/omnirush/bgshell-lib.ts +400 -0
  7. package/assets/extensions/omnirush/bgshell.ts +392 -0
  8. package/assets/extensions/omnirush/collector.ts +103 -16
  9. package/assets/extensions/omnirush/commands.ts +14 -15
  10. package/assets/extensions/omnirush/deliveries.ts +145 -0
  11. package/assets/extensions/omnirush/guard/UPSTREAM +2 -0
  12. package/assets/extensions/omnirush/guard/git-command-policy.ts +885 -0
  13. package/assets/extensions/omnirush/guard-lib.ts +230 -0
  14. package/assets/extensions/omnirush/guard.ts +340 -0
  15. package/assets/extensions/omnirush/index.ts +12 -0
  16. package/assets/extensions/omnirush/pi-engine.ts +65 -1
  17. package/assets/extensions/omnirush/sota.ts +104 -47
  18. package/assets/extensions/omnirush/status-lib.ts +3 -0
  19. package/assets/extensions/omnirush/subagents-lib.ts +623 -0
  20. package/assets/extensions/omnirush/subagents.ts +305 -0
  21. package/assets/extensions/omnirush/swarm-lib.ts +142 -0
  22. package/assets/extensions/omnirush/swarm.ts +95 -0
  23. package/assets/extensions/omnirush/voice/capture.ts +502 -0
  24. package/assets/extensions/omnirush/voice/core/UPSTREAM +16 -0
  25. package/assets/extensions/omnirush/voice/core/file-source.ts +70 -0
  26. package/assets/extensions/omnirush/voice/core/index.ts +21 -0
  27. package/assets/extensions/omnirush/voice/core/keyterms.ts +117 -0
  28. package/assets/extensions/omnirush/voice/core/resample.ts +63 -0
  29. package/assets/extensions/omnirush/voice/core/segmenter.ts +231 -0
  30. package/assets/extensions/omnirush/voice/core/session.ts +403 -0
  31. package/assets/extensions/omnirush/voice/core/text.ts +81 -0
  32. package/assets/extensions/omnirush/voice/core/transcriber.ts +135 -0
  33. package/assets/extensions/omnirush/voice/core/types.ts +102 -0
  34. package/assets/extensions/omnirush/voice/core/wav.ts +95 -0
  35. package/assets/extensions/omnirush/voice/keys.ts +435 -0
  36. package/assets/extensions/omnirush/voice/kitty.ts +64 -0
  37. package/assets/extensions/omnirush/voice/pvrecorder-worker.cjs +43 -0
  38. package/assets/extensions/omnirush/voice/settings.ts +67 -0
  39. package/assets/extensions/omnirush/voice.ts +838 -0
  40. package/assets/extensions/omnirush/yolo-lib.ts +80 -0
  41. package/assets/extensions/omnirush/yolo.ts +85 -0
  42. package/package.json +6 -2
  43. package/scripts/brand-engine.js +526 -0
  44. package/scripts/build-all-packages.py +29 -1
  45. package/scripts/smoke-packages.py +32 -1
  46. package/src/bin.js +252 -53
  47. package/src/compat.js +272 -0
  48. package/src/lib.js +64 -0
  49. package/scripts/patch-pi-branding.js +0 -251
@@ -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
- function readJsonFile(filePath) {
85
+ /** Synchronous sleep (tiny waits between file-system retries only). */
86
+ function sleepSync(ms) {
82
87
  try {
83
- return JSON.parse(fs.readFileSync(filePath, "utf8"));
88
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
84
89
  } catch {
85
- return null;
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
- fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
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 (issue B) --------------------------
371
+ // --- cross-process refresh coordination ------------------------------------
305
372
  //
306
- // Several omnirush processes on one machine share ~/.omnirush/auth.json.
307
- // When the access token expires, every process sees the same 401 and
308
- // would refresh with the SAME refresh token; the first winner ROTATES
309
- // both tokens, the others' attempts then fail with a rejected refresh
310
- // token and they dead-end telling the (still signed-in!) user to log in
311
- // again. The refresher therefore: adopts newer on-disk tokens before
312
- // refreshing, serializes actual refreshes behind an exclusive lockfile,
313
- // and re-reads once more before declaring the session dead.
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 = 35_000;
318
- const REFRESH_LOCK_POLL_MS = 200;
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). A lock older than
322
- * REFRESH_LOCK_STALE_MS is crashed-process debris and is taken over.
323
- * Resolves true when WE hold the lock, false when it could not be
324
- * acquired within the wait budget (caller proceeds unlocked — an extra
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, sleep) {
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 deadline = Date.now() + REFRESH_LOCK_WAIT_MS;
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
- const stats = fs.statSync(lockPath);
340
- if (Date.now() - stats.mtimeMs > REFRESH_LOCK_STALE_MS) {
341
- fs.rmSync(lockPath, { force: true });
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
- } catch {
345
- continue; // vanished — retry the create
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 (Date.now() >= deadline) return false;
349
- await sleep(REFRESH_LOCK_POLL_MS);
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 we still own it. */
354
- function releaseRefreshLock(dir) {
355
- const lockPath = path.join(dir, REFRESH_LOCK_FILE);
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
- const content = fs.readFileSync(lockPath, "utf8");
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 refresh token is dead. */
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
- throw new RefreshRejectedError(detailOf(payload, `HTTP ${response.status}`));
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
- * Single-flight token refresher (the GUI broker pattern): concurrent
432
- * callers share one HTTP refresh; a caller whose expected token was
433
- * already rotated resolves true without touching the network. Rotated
434
- * credentials persist back to the state dir (0600) before resolution.
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({ dir, origin, fetchImpl, now = () => Date.now() } = {}) {
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
- let auth = dir ? loadAuth(dir) : null;
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
- const setAuth = (next) => {
443
- auth = next;
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, attempt = 0) => {
447
- try {
448
- saveAuth(dir, next);
449
- lastPersistError = null;
450
- } catch (error) {
451
- lastPersistError = error?.message ?? "persist failed";
452
- if (attempt < 3 && auth?.refreshToken === next.refreshToken) {
453
- await new Promise((resolve) => setTimeout(resolve, 250 * 2 ** attempt));
454
- await persist(next, attempt + 1);
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 performRefresh = async (expectedAccessToken) => {
460
- if (!auth || auth.accessToken !== expectedAccessToken) return true;
461
- // 1. Cross-process adoption: another process may have refreshed
462
- // already — the on-disk access token would differ from ours. Adopt
463
- // it and let the caller retry; no refresh call, no rotation race.
464
- const onDiskBefore = dir ? loadAuth(dir) : null;
465
- if (onDiskBefore && onDiskBefore.accessToken && onDiskBefore.accessToken !== expectedAccessToken) {
466
- setAuth(onDiskBefore);
467
- return true;
468
- }
469
- // 2. Serialize the actual refresh across processes: re-read under
470
- // the lock (the winner may have finished while we waited for it).
471
- const haveLock = dir
472
- ? await acquireRefreshLock(dir, (ms) => new Promise((resolve) => setTimeout(resolve, ms)))
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
- const underLock = dir ? loadAuth(dir) : null;
476
- if (underLock && underLock.accessToken && underLock.accessToken !== expectedAccessToken) {
477
- setAuth(underLock);
478
- return true;
479
- }
480
- const attemptRefreshToken = underLock?.refreshToken ?? auth.refreshToken;
481
- let next;
482
- try {
483
- next = await refreshTokens(origin, { refreshToken: attemptRefreshToken, fetchImpl: doFetch });
484
- } catch (error) {
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
- return false;
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
- throw error;
729
+ memory = next;
730
+ if (dir) await persist(next);
731
+ deadFor = null;
732
+ return ok(next);
499
733
  }
500
- setAuth(next);
501
- await persist(next);
502
- return true;
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
- if (haveLock) releaseRefreshLock(dir);
754
+ inflight = null;
505
755
  }
506
756
  };
507
757
 
508
758
  return {
509
759
  get auth() {
510
- return auth;
760
+ return reload();
511
761
  },
512
762
  get lastPersistError() {
513
763
  return lastPersistError;
514
764
  },
515
- setAuth(next) {
516
- setAuth(next);
765
+ accessToken() {
766
+ return reload()?.accessToken ?? "";
517
767
  },
518
- reload() {
519
- auth = dir ? loadAuth(dir) : null;
768
+ setAuth(next) {
769
+ memory = next;
520
770
  },
771
+ reload,
521
772
  now,
773
+ recover,
522
774
  async refresh(expectedAccessToken) {
523
- if (!auth) return false;
524
- if (auth.accessToken !== expectedAccessToken) return true;
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