@crouton-kit/crouter 0.3.70 → 0.3.79

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 (193) hide show
  1. package/README.md +1 -1
  2. package/dist/build-root.d.ts +12 -4
  3. package/dist/build-root.js +25 -6
  4. package/dist/builtin-memory/00-runtime-base.md +3 -10
  5. package/dist/builtin-memory/04-base-worker.md +18 -0
  6. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  7. package/dist/builtin-memory/crouter-development/plugins.md +82 -5
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1115 -1
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +234 -71
  11. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
  12. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -516
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
  14. package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
  15. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
  16. package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
  17. package/dist/builtin-views/canvas/tui.mjs +8 -9
  18. package/dist/builtin-views/chat/tui.mjs +12 -12
  19. package/dist/builtin-views/git-pr/tui.mjs +7 -8
  20. package/dist/builtin-views/inbox/tui.mjs +27 -41
  21. package/dist/builtin-views/linkedin/tui.mjs +23 -37
  22. package/dist/builtin-views/prompt-review/tui.mjs +11 -11
  23. package/dist/builtin-views/settings/tui.mjs +11 -11
  24. package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
  25. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +11 -6
  26. package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
  27. package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
  28. package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
  29. package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
  30. package/dist/clients/attach/__tests__/mermaid-render.test.js +28 -0
  31. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
  32. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
  33. package/dist/clients/attach/attach-cmd.d.ts +9 -1
  34. package/dist/clients/attach/attach-cmd.js +841 -792
  35. package/dist/clients/attach/auth-pickers.d.ts +0 -12
  36. package/dist/clients/attach/auth-pickers.js +64 -15
  37. package/dist/clients/attach/canvas-panels.js +2 -3
  38. package/dist/clients/attach/chat-view.d.ts +7 -8
  39. package/dist/clients/attach/chat-view.js +119 -79
  40. package/dist/clients/attach/edit-diff-render.d.ts +6 -0
  41. package/dist/clients/attach/edit-diff-render.js +263 -0
  42. package/dist/clients/attach/graph-overlay.d.ts +12 -2
  43. package/dist/clients/attach/graph-overlay.js +83 -33
  44. package/dist/clients/attach/input-controller.d.ts +28 -2
  45. package/dist/clients/attach/input-controller.js +38 -5
  46. package/dist/clients/attach/mermaid-render.js +5 -2
  47. package/dist/clients/attach/pickers.d.ts +8 -7
  48. package/dist/clients/attach/pickers.js +11 -17
  49. package/dist/clients/attach/slash-commands.d.ts +9 -0
  50. package/dist/clients/attach/slash-commands.js +127 -7
  51. package/dist/clients/attach/titled-editor.d.ts +12 -1
  52. package/dist/clients/attach/titled-editor.js +103 -8
  53. package/dist/commands/canvas-browse.js +2 -2
  54. package/dist/commands/human/queue.js +3 -4
  55. package/dist/commands/memory/lint.js +39 -5
  56. package/dist/commands/memory/write.js +1 -0
  57. package/dist/commands/node.js +9 -2
  58. package/dist/commands/pkg/plugin-inspect.js +22 -1
  59. package/dist/commands/pkg/plugin-manage.js +31 -9
  60. package/dist/commands/surface-tmux-spread.js +1 -3
  61. package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
  62. package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
  63. package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
  64. package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
  65. package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
  66. package/dist/commands/sys/config.js +18 -21
  67. package/dist/commands/sys/doctor.js +42 -4
  68. package/dist/commands/sys/setup-core.d.ts +49 -1
  69. package/dist/commands/sys/setup-core.js +161 -4
  70. package/dist/commands/sys/setup.d.ts +88 -0
  71. package/dist/commands/sys/setup.js +943 -169
  72. package/dist/commands/view-pick.d.ts +4 -0
  73. package/dist/commands/view-pick.js +17 -7
  74. package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
  75. package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
  76. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
  77. package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
  78. package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
  79. package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
  80. package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
  81. package/dist/core/__tests__/command-plugins.test.js +444 -0
  82. package/dist/core/__tests__/fault-classifier.test.js +15 -0
  83. package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
  84. package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
  85. package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
  86. package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
  87. package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
  88. package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
  89. package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
  90. package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
  91. package/dist/core/__tests__/stream-watchdog.test.js +70 -0
  92. package/dist/core/__tests__/tmux-surface.test.js +72 -0
  93. package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
  94. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  95. package/dist/core/canvas/browse/app.d.ts +6 -0
  96. package/dist/core/canvas/browse/app.js +230 -41
  97. package/dist/core/canvas/browse/model.d.ts +10 -6
  98. package/dist/core/canvas/browse/model.js +20 -12
  99. package/dist/core/canvas/browse/pins.d.ts +4 -0
  100. package/dist/core/canvas/browse/pins.js +29 -0
  101. package/dist/core/canvas/browse/render.d.ts +13 -1
  102. package/dist/core/canvas/browse/render.js +72 -15
  103. package/dist/core/canvas/nav-model.js +5 -11
  104. package/dist/core/canvas/paths.d.ts +5 -0
  105. package/dist/core/canvas/paths.js +7 -0
  106. package/dist/core/command-plugins/adapter.d.ts +15 -0
  107. package/dist/core/command-plugins/adapter.js +145 -0
  108. package/dist/core/command-plugins/compose.d.ts +5 -0
  109. package/dist/core/command-plugins/compose.js +56 -0
  110. package/dist/core/command-plugins/discovery.d.ts +104 -0
  111. package/dist/core/command-plugins/discovery.js +565 -0
  112. package/dist/core/config.d.ts +48 -0
  113. package/dist/core/config.js +228 -110
  114. package/dist/core/fault-classifier.js +1 -1
  115. package/dist/core/fs-utils.d.ts +1 -0
  116. package/dist/core/fs-utils.js +15 -1
  117. package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
  118. package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
  119. package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
  120. package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
  121. package/dist/core/keybindings/catalog.d.ts +14 -0
  122. package/dist/core/keybindings/catalog.js +257 -0
  123. package/dist/core/keybindings/index.d.ts +5 -0
  124. package/dist/core/keybindings/index.js +4 -0
  125. package/dist/core/keybindings/match.d.ts +29 -0
  126. package/dist/core/keybindings/match.js +99 -0
  127. package/dist/core/keybindings/persistence.d.ts +14 -0
  128. package/dist/core/keybindings/persistence.js +31 -0
  129. package/dist/core/keybindings/resolve.d.ts +14 -0
  130. package/dist/core/keybindings/resolve.js +305 -0
  131. package/dist/core/keybindings/types.d.ts +37 -0
  132. package/dist/core/keybindings/types.js +1 -0
  133. package/dist/core/memory-resolver.d.ts +1 -1
  134. package/dist/core/memory-resolver.js +10 -3
  135. package/dist/core/predicate.d.ts +5 -3
  136. package/dist/core/predicate.js +5 -3
  137. package/dist/core/profiles/select.d.ts +6 -0
  138. package/dist/core/profiles/select.js +86 -52
  139. package/dist/core/provider-management.d.ts +12 -0
  140. package/dist/core/provider-management.js +24 -0
  141. package/dist/core/runtime/broker-protocol.d.ts +33 -5
  142. package/dist/core/runtime/broker.js +164 -14
  143. package/dist/core/runtime/launch.d.ts +39 -6
  144. package/dist/core/runtime/launch.js +78 -20
  145. package/dist/core/runtime/naming.js +3 -3
  146. package/dist/core/runtime/pi-cli.d.ts +6 -0
  147. package/dist/core/runtime/pi-cli.js +16 -2
  148. package/dist/core/runtime/pi-vendored.d.ts +8 -0
  149. package/dist/core/runtime/pi-vendored.js +14 -0
  150. package/dist/core/runtime/placement.d.ts +2 -2
  151. package/dist/core/runtime/placement.js +4 -1
  152. package/dist/core/runtime/promote.js +4 -0
  153. package/dist/core/runtime/recap.d.ts +3 -3
  154. package/dist/core/runtime/recap.js +75 -47
  155. package/dist/core/runtime/recycle.js +6 -1
  156. package/dist/core/runtime/reset.js +5 -0
  157. package/dist/core/runtime/session-list-cache.d.ts +33 -0
  158. package/dist/core/runtime/session-list-cache.js +338 -0
  159. package/dist/core/runtime/spawn.js +52 -45
  160. package/dist/core/runtime/stream-watchdog.d.ts +26 -0
  161. package/dist/core/runtime/stream-watchdog.js +75 -0
  162. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  163. package/dist/core/runtime/tmux-chrome.js +2 -2
  164. package/dist/core/runtime/tmux.d.ts +18 -15
  165. package/dist/core/runtime/tmux.js +217 -110
  166. package/dist/core/scope.js +27 -4
  167. package/dist/core/subscription-state.d.ts +90 -0
  168. package/dist/core/subscription-state.js +762 -0
  169. package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
  170. package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
  171. package/dist/core/tui/host.d.ts +30 -1
  172. package/dist/core/tui/host.js +148 -34
  173. package/dist/core/view/contract.d.ts +17 -3
  174. package/dist/daemon/crtrd.js +253 -12
  175. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
  176. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
  177. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
  178. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
  179. package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
  180. package/dist/pi-extensions/canvas-recap.d.ts +4 -0
  181. package/dist/pi-extensions/canvas-recap.js +73 -46
  182. package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
  183. package/dist/pi-extensions/canvas-stophook.js +33 -4
  184. package/dist/types.d.ts +9 -26
  185. package/dist/types.js +1 -28
  186. package/dist/web-client/assets/{index-DiFuLcp6.js → index--md2ylfi.js} +1 -1
  187. package/dist/web-client/index.html +1 -1
  188. package/dist/web-client/sw.js +1 -1
  189. package/package.json +8 -4
  190. package/dist/pi-extensions/canvas-nav.d.ts +0 -43
  191. package/dist/pi-extensions/canvas-nav.js +0 -640
  192. package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
  193. package/dist/pi-extensions/widget-order-bus.js +0 -34
@@ -1,12 +1,14 @@
1
1
  import { before, after, test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { spawn } from "node:child_process";
4
- import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
+ import { FileAuthStorageBackend } from "@earendil-works/pi-coding-agent";
5
6
  import { dirname, join } from "node:path";
6
7
  import { tmpdir } from "node:os";
7
8
 
8
9
  let home: string;
9
10
  let helpers: typeof import("../../lib/subscription-state.ts");
11
+ let providerManagement: typeof import("../../../../core/provider-management.ts");
10
12
  let rotationModule: typeof import("../provider-rotation.ts");
11
13
  let providerRotationExtension: typeof import("../provider-rotation.ts").default;
12
14
  const previousHome = process.env.HOME;
@@ -83,10 +85,27 @@ function observeLines(stream: NodeJS.ReadableStream, onLine: (line: string) => v
83
85
  });
84
86
  }
85
87
 
88
+ function within<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
89
+ return new Promise<T>((resolve, reject) => {
90
+ const timer = setTimeout(() => reject(new Error(message)), ms);
91
+ promise.then(
92
+ (value) => {
93
+ clearTimeout(timer);
94
+ resolve(value);
95
+ },
96
+ (error: unknown) => {
97
+ clearTimeout(timer);
98
+ reject(error);
99
+ },
100
+ );
101
+ });
102
+ }
103
+
86
104
  before(async () => {
87
105
  home = mkdtempSync(join(tmpdir(), "pi-provider-rotation-"));
88
106
  process.env.HOME = home;
89
107
  helpers = await import("../../lib/subscription-state.ts");
108
+ providerManagement = await import("../../../../core/provider-management.ts");
90
109
  rotationModule = await import("../provider-rotation.ts");
91
110
  providerRotationExtension = rotationModule.default;
92
111
  });
@@ -522,6 +541,24 @@ test("/provider-sub add with the default label persists the login credential to
522
541
  }
523
542
  });
524
543
 
544
+ // The retained agent command must use the same coordinated removal as the Providers panel:
545
+ // removing the default metadata slot also removes auth.json, so a later read cannot re-seed it.
546
+ test("/provider-sub rm removes the default slot and its auth.json credential together", async () => {
547
+ resetRotationState();
548
+ const authPath = join(home, ".pi", "agent", "auth.json");
549
+ mkdirSync(dirname(authPath), { recursive: true });
550
+ writeFileSync(authPath, `${JSON.stringify({ anthropic: { type: "oauth", refresh: "refresh-default", access: "access-default", expires: Date.now() + 60_000 } })}\n`, "utf8");
551
+ assert.equal(helpers.readSubscriptionPool("anthropic")[0]?.label, "anthropic");
552
+
553
+ const { ctx, commands, pi } = makeRotationCtx();
554
+ ctx.ui.notify = () => {};
555
+ await providerRotationExtension(pi);
556
+ await commands["provider-sub"].handler("anthropic rm anthropic", ctx);
557
+
558
+ assert.equal(JSON.parse(readFileSync(authPath, "utf8")).anthropic, undefined);
559
+ assert.equal(helpers.readSubscriptionPool("anthropic").some((entry) => entry.label === "anthropic"), false);
560
+ });
561
+
525
562
  // A locked auth.json write failure must not let the default-label add path clear the
526
563
  // pool cooldown bookkeeping or pretend the add succeeded.
527
564
  test("/provider-sub add with the default label keeps the pool cooldown when auth.json write fails", async () => {
@@ -561,6 +598,97 @@ test("/provider-sub add with the default label keeps the pool cooldown when auth
561
598
  }
562
599
  });
563
600
 
601
+ // loginWithManualInputCleanup (reviews mrf5doek + mrf68z4h): the manual-code-paste
602
+ // dialog races pi-ai's loopback callback server. These exercise the wrapper directly
603
+ // (not through __setLoginForProviderForTest, which bypasses it entirely) against a
604
+ // fake `run` that mimics pi-ai's own "call onManualCodeInput, maybe never await it"
605
+ // behavior. Dismissal is now correlated at the BROKER (aborting the dialog's signal
606
+ // drops its pending entry AND emits an extension_ui_dismiss frame keyed to the
607
+ // request id), not a broad notify -- so these assert on the SIGNAL, not a notify.
608
+
609
+ test("loginWithManualInputCleanup aborts the still-open manual dialog's signal once the OAuth call settles without it (loopback-wins parity)", async () => {
610
+ let capturedSignal: AbortSignal | undefined;
611
+ const notifications: Array<{ message: string; type?: string }> = [];
612
+ const ctx = {
613
+ ui: {
614
+ notify: (message: string, type?: string) => notifications.push({ message, type }),
615
+ input: (_title: string, _placeholder: string, opts?: { signal?: AbortSignal }) => {
616
+ capturedSignal = opts?.signal;
617
+ // Never resolves on its own -- mirrors pi-ai abandoning the manual-input
618
+ // promise once the loopback callback server wins the race.
619
+ return new Promise<string | undefined>(() => {});
620
+ },
621
+ },
622
+ } as any;
623
+
624
+ const result = await rotationModule.loginWithManualInputCleanup(ctx, async (readCode: any) => {
625
+ // Start the manual-code prompt (as onManualCodeInput would) but -- like the real
626
+ // pi-ai race -- never await its outcome; the loopback callback "wins" instead.
627
+ void readCode("Open this URL...", "placeholder").catch(() => {});
628
+ return "loopback-code";
629
+ });
630
+
631
+ assert.equal(result, "loopback-code");
632
+ assert.equal(capturedSignal?.aborted, true, "the abandoned manual-input dialog's signal must be aborted once the OAuth call settles -- the broker turns that abort into a correlated extension_ui_dismiss");
633
+ assert.equal(notifications.length, 0, "no broad dismiss notify -- an unrelated notify must never be used as a dialog-dismissal signal");
634
+ });
635
+
636
+ test("loginWithManualInputCleanup dismisses the manual dialog at callback-win (onProgress), NOT after a delayed token exchange", async () => {
637
+ // Reproduces review mrf68z4h's OpenAI/pi-ai-shaped concern: pi-ai gets the code
638
+ // from the loopback callback, reports progress ("exchanging..."), then awaits a
639
+ // token exchange that has no timeout. The manual dialog must be aborted at the
640
+ // callback-win boundary (dismissManual, wired to onProgress), before the exchange
641
+ // resolves `run` -- otherwise a slow/hung exchange leaves the overlay up.
642
+ let order = 0;
643
+ let abortedAt: number | undefined;
644
+ let runResolvedAt: number | undefined;
645
+ const ctx = {
646
+ ui: {
647
+ notify: () => {},
648
+ input: (_title: string, _placeholder: string, opts?: { signal?: AbortSignal }) =>
649
+ // Mirror the broker: resolve to undefined on abort (which readCode turns
650
+ // into a throw, inert on the abandoned promise like pi-ai's own .catch).
651
+ new Promise<string | undefined>((resolve) => {
652
+ opts?.signal?.addEventListener("abort", () => { abortedAt = order++; resolve(undefined); }, { once: true });
653
+ }),
654
+ },
655
+ } as any;
656
+
657
+ const result = await rotationModule.loginWithManualInputCleanup(ctx, async (readCode: any, dismissManual: () => void) => {
658
+ void readCode("Open this URL...", "placeholder").catch(() => {});
659
+ dismissManual(); // pi-ai's onProgress fires here -- it already has the code
660
+ await new Promise((r) => setTimeout(r, 25)); // the (possibly slow) token exchange
661
+ runResolvedAt = order++;
662
+ return "exchanged-token";
663
+ });
664
+
665
+ assert.equal(result, "exchanged-token");
666
+ assert.notEqual(abortedAt, undefined, "the manual dialog's signal must have been aborted");
667
+ assert.notEqual(runResolvedAt, undefined);
668
+ assert.ok(abortedAt! < runResolvedAt!, "the manual dialog must be dismissed at callback-win (onProgress), BEFORE the delayed token exchange completes");
669
+ });
670
+
671
+ test("loginWithManualInputCleanup's readCode rejects (rather than resolving to \"\") when the manual dialog is cancelled or times out", async () => {
672
+ const ctx = {
673
+ ui: {
674
+ notify: () => {},
675
+ // ctx.ui.input resolving to undefined is exactly what a Cancel button or the
676
+ // dialog's own timeout produces (dialogPromise's cancelled/timeout default).
677
+ input: async () => undefined,
678
+ },
679
+ } as any;
680
+
681
+ // pi-ai only calls its onPrompt paste-only fallback when onManualCodeInput
682
+ // RESOLVES (even to a falsy value) -- never when it rejects. Asserting
683
+ // loginWithManualInputCleanup itself rejects (instead of resolving to "", the
684
+ // pre-fix behavior) proves readCode ends the OAuth attempt directly and pi-ai
685
+ // never gets the chance to fall through to that second, URL-less dialog.
686
+ await assert.rejects(
687
+ () => rotationModule.loginWithManualInputCleanup(ctx, (readCode) => readCode("Open this URL...", "placeholder")),
688
+ /OAuth login cancelled/,
689
+ );
690
+ });
691
+
564
692
  // The default metadata slot is seeded whenever auth.json holds an OAuth credential for
565
693
  // the provider, even when the pool already contains explicit non-default accounts.
566
694
  test("seeds the default metadata slot even when the pool already has an explicit non-default account", () => {
@@ -835,6 +963,710 @@ test("keeps newest Retry-After cooldown across overlapping attempts", () => {
835
963
  assert.equal(helpers.readSubscriptionPool("anthropic")[0].rateLimitedUntil, 0);
836
964
  });
837
965
 
966
+ // Storage-hardening (design mrf6n12x): the locked read/modify/write primitive must preserve a
967
+ // causally newer cooldown across a stale credential upsert, while still letting an equal-timestamp
968
+ // reauth clear it.
969
+ test("a stale upsert refreshes tokens but cannot erase a newer cooldown; an equal-timestamp reauth clears it", () => {
970
+ resetRotationState();
971
+ helpers.writeSubscriptionPool("anthropic", [credential("work", { refresh: "r0", access: "a0", lastRateLimitedAt: 100 })]);
972
+ // What a node reads before doing a slow token refresh.
973
+ const snapshot = helpers.readSubscriptionPool("anthropic")[0];
974
+
975
+ // "Other node" 429s the slot with a strictly newer basis while the refresh is in flight.
976
+ helpers.markSubscriptionRateLimited("anthropic", "work", 60_000, 500, 500);
977
+ const cooled = helpers.readSubscriptionPool("anthropic")[0];
978
+ assert.equal(cooled.lastRateLimitedAt, 500);
979
+ assert.equal(cooled.rateLimitedUntil, 60_500);
980
+
981
+ // Stale refresh upsert built from the pre-429 snapshot: fresh tokens, stale cooldown basis.
982
+ helpers.upsertSubscription("anthropic", { ...snapshot, refresh: "r1", access: "a1", expires: snapshot.expires ?? 0 });
983
+ const merged = helpers.readSubscriptionPool("anthropic")[0];
984
+ assert.equal(merged.refresh, "r1", "token fields take the incoming value");
985
+ assert.equal(merged.access, "a1");
986
+ assert.equal(merged.rateLimitedUntil, 60_500, "the newer cooldown survives the stale upsert");
987
+ assert.equal(merged.lastRateLimitedAt, 500);
988
+
989
+ // Reauth clears the cooldown while preserving lastRateLimitedAt (equal timestamp -> incoming wins).
990
+ helpers.upsertSubscription("anthropic", { ...merged, refresh: "r2", access: "a2", rateLimitedUntil: 0, lastRateLimitedAt: merged.lastRateLimitedAt });
991
+ const reauthed = helpers.readSubscriptionPool("anthropic")[0];
992
+ assert.equal(reauthed.rateLimitedUntil, 0, "an equal-timestamp reauth clears the cooldown");
993
+ assert.equal(reauthed.refresh, "r2");
994
+ });
995
+
996
+ test("a stale success cannot reopen a slot cooled down by a newer 429", () => {
997
+ resetRotationState();
998
+ helpers.writeSubscriptionPool("anthropic", [credential("personal")]);
999
+ helpers.markSubscriptionRateLimited("anthropic", "personal", 30_000, 500, 500);
1000
+ // A success whose attempt STARTED before the 429 must not clear the newer cooldown.
1001
+ helpers.markSubscriptionSuccess("anthropic", "personal", 200);
1002
+ assert.equal(helpers.readSubscriptionPool("anthropic")[0].rateLimitedUntil, 30_500);
1003
+ // A success that started after the 429 legitimately clears it.
1004
+ helpers.markSubscriptionSuccess("anthropic", "personal", 600);
1005
+ assert.equal(helpers.readSubscriptionPool("anthropic")[0].rateLimitedUntil, 0);
1006
+ });
1007
+
1008
+ // A mutation must genuinely go through the per-pool-file cross-process lock: while another
1009
+ // process holds the pool file's lock (the SAME FileAuthStorageBackend lock the backend uses),
1010
+ // a mutator throws after exhausting its retry budget rather than writing unlocked; once the
1011
+ // holder releases, the same call succeeds.
1012
+ test("pool mutations take the cross-process pool-file lock", async () => {
1013
+ resetRotationState();
1014
+ helpers.writeSubscriptionPool("openai-codex", [credential("work")]);
1015
+ const poolFile = join(home, ".pi", "agent", "openai-codex-subscriptions.json");
1016
+
1017
+ const lockHolder = spawn(
1018
+ process.execPath,
1019
+ [
1020
+ "--import",
1021
+ "tsx/esm",
1022
+ "--input-type=module",
1023
+ "-e",
1024
+ String.raw`import { FileAuthStorageBackend } from "@earendil-works/pi-coding-agent";
1025
+ const backend = new FileAuthStorageBackend(${JSON.stringify(poolFile)});
1026
+ await backend.withLockAsync(async () => {
1027
+ process.stdout.write("locked\n");
1028
+ await new Promise((resolve) => {
1029
+ process.stdin.setEncoding("utf8");
1030
+ process.stdin.once("data", resolve);
1031
+ });
1032
+ return { result: undefined };
1033
+ });`,
1034
+ ],
1035
+ { env: { ...process.env, HOME: home }, stdio: ["pipe", "pipe", "pipe"] },
1036
+ );
1037
+ let lockStderr = "";
1038
+ lockHolder.stderr!.setEncoding("utf8");
1039
+ lockHolder.stderr!.on("data", (chunk: string) => {
1040
+ lockStderr += chunk;
1041
+ });
1042
+ let sawLocked = false;
1043
+ const lockReady = new Promise<void>((resolve, reject) => {
1044
+ lockHolder.once("error", reject);
1045
+ lockHolder.once("close", (code) => {
1046
+ if (!sawLocked) reject(new Error(`lock-holder exited with ${code} before signalling readiness: ${lockStderr.trim()}`));
1047
+ });
1048
+ observeLines(lockHolder.stdout!, (line) => {
1049
+ if (line === "locked") {
1050
+ sawLocked = true;
1051
+ resolve();
1052
+ }
1053
+ });
1054
+ });
1055
+ // Teardown releases the lock and proves the child completed its awaited unlock before the
1056
+ // post-release mutation runs. A wedged child still gets bounded TERM/KILL cleanup.
1057
+ const childClosed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) =>
1058
+ lockHolder.once("close", (code, signal) => resolve({ code, signal })),
1059
+ );
1060
+ let readyTimer: NodeJS.Timeout | undefined;
1061
+ try {
1062
+ await Promise.race([
1063
+ lockReady,
1064
+ new Promise<never>((_, reject) => {
1065
+ // Boot budget, not a semantics guard: the child is a fresh `node --import tsx/esm`
1066
+ // process that compiles the TS module graph before it can signal. Under the full
1067
+ // tier's --test-concurrency=4 on a 4-vCPU CI runner that boot alone can exceed 10s,
1068
+ // so give it real headroom -- a genuine lock bug shows up as a wrong outcome below,
1069
+ // never as a slow boot.
1070
+ readyTimer = setTimeout(() => reject(new Error("lock-holder did not signal readiness within 60s")), 60_000);
1071
+ }),
1072
+ ]);
1073
+ assert.throws(() => helpers.markSubscriptionAttempt("openai-codex", "work", 123), /ELOCKED|already being held/i);
1074
+ } finally {
1075
+ if (readyTimer) clearTimeout(readyTimer);
1076
+ try {
1077
+ lockHolder.stdin!.write("release\n");
1078
+ lockHolder.stdin!.end();
1079
+ } catch {
1080
+ /* child already gone */
1081
+ }
1082
+ // Wait for clean exit first: code 0 proves the async unlock completed. A wedged child gets
1083
+ // bounded SIGTERM/SIGKILL cleanup, and its test-owned lock is removed only on that fallback.
1084
+ const closedWithin = (ms: number) =>
1085
+ Promise.race([
1086
+ childClosed,
1087
+ new Promise<null>((resolve) => setTimeout(() => resolve(null), ms)),
1088
+ ]);
1089
+ let closed = await closedWithin(2000);
1090
+ let forced = false;
1091
+ if (!closed || closed.code !== 0) {
1092
+ forced = true;
1093
+ lockHolder.kill("SIGTERM");
1094
+ closed = await closedWithin(2000);
1095
+ if (!closed) {
1096
+ lockHolder.kill("SIGKILL");
1097
+ closed = await closedWithin(2000);
1098
+ }
1099
+ if (!closed) throw new Error("lock-holder child did not exit after SIGKILL");
1100
+ }
1101
+ if (forced) {
1102
+ rmSync(`${poolFile}.lock`, { recursive: true, force: true });
1103
+ throw new Error(`lock-holder exited without clean unlock (code=${closed.code}, signal=${closed.signal})`);
1104
+ }
1105
+ }
1106
+
1107
+ const pool = helpers.markSubscriptionAttempt("openai-codex", "work", 456);
1108
+ assert.equal(pool[0].lastAttemptAt, 456);
1109
+ });
1110
+
1111
+ // Codex captures the login `accountId` and rejects the same account re-added under a different
1112
+ // label (a sticky browser session otherwise fakes redundancy on one quota); a distinct account
1113
+ // is stored with its own accountId.
1114
+ test("rejects a duplicate Codex account under a new label and stores accountId for a distinct one", async () => {
1115
+ resetRotationState();
1116
+ const { ctx, commands, pi } = makeRotationCtx();
1117
+ ctx.hasUI = true;
1118
+ const notes: Array<{ message: string; type?: string }> = [];
1119
+ ctx.ui.notify = (message: string, type?: string) => notes.push({ message, type });
1120
+ ctx.ui.input = async () => "";
1121
+ let nextAccountId = "acct-1";
1122
+ rotationModule.__setLoginForProviderForTest(async () => ({ refresh: "r", access: "a", expires: Date.now() + 60_000, accountId: nextAccountId }));
1123
+
1124
+ try {
1125
+ await providerRotationExtension(pi);
1126
+
1127
+ await commands["provider-sub"].handler("openai-codex add codex-alt", ctx);
1128
+ let pool = helpers.readSubscriptionPool("openai-codex");
1129
+ assert.deepEqual(pool.map((e) => e.label), ["codex-alt"]);
1130
+ assert.equal(pool[0].accountId, "acct-1");
1131
+
1132
+ // Same account, different label -> rejected, pool unchanged.
1133
+ notes.length = 0;
1134
+ await commands["provider-sub"].handler("openai-codex add codex-dup", ctx);
1135
+ assert.ok(notes.some((n) => n.type === "error" && /already added/i.test(n.message)), "duplicate account is rejected with an error");
1136
+ pool = helpers.readSubscriptionPool("openai-codex");
1137
+ assert.deepEqual(pool.map((e) => e.label), ["codex-alt"]);
1138
+
1139
+ // Distinct account -> stored with its own accountId.
1140
+ nextAccountId = "acct-2";
1141
+ await commands["provider-sub"].handler("openai-codex add codex-other", ctx);
1142
+ pool = helpers.readSubscriptionPool("openai-codex");
1143
+ assert.deepEqual(pool.map((e) => e.label).sort(), ["codex-alt", "codex-other"]);
1144
+ assert.equal(pool.find((e) => e.label === "codex-other")?.accountId, "acct-2");
1145
+ } finally {
1146
+ rotationModule.__setLoginForProviderForTest(undefined);
1147
+ }
1148
+ });
1149
+
1150
+ // Review finding 1: the DEFAULT Codex slot must carry its account identity too, so the account
1151
+ // already backing the default login cannot be re-added under a second label, and list shows it.
1152
+ test("the default Codex slot carries its account identity: blocks re-adding the same account and renders it in list", async () => {
1153
+ resetRotationState();
1154
+ const authPath = join(home, ".pi", "agent", "auth.json");
1155
+ mkdirSync(dirname(authPath), { recursive: true });
1156
+ writeFileSync(
1157
+ authPath,
1158
+ `${JSON.stringify({ "openai-codex": { type: "oauth", refresh: "r-def", access: "a-def", expires: Date.now() + 60_000, accountId: "acct-default" } })}\n`,
1159
+ "utf8",
1160
+ );
1161
+
1162
+ // Reading the pool seeds a metadata-only default entry that carries the account identity --
1163
+ // no token value copied into the pool.
1164
+ let pool = helpers.readSubscriptionPool("openai-codex");
1165
+ assert.deepEqual(pool.map((e) => e.label), ["openai-codex"]);
1166
+ assert.equal(pool[0].accountId, "acct-default");
1167
+ assert.equal(pool[0].refresh, undefined);
1168
+
1169
+ const { ctx, commands, pi } = makeRotationCtx();
1170
+ ctx.hasUI = true;
1171
+ const notes: Array<{ message: string; type?: string }> = [];
1172
+ ctx.ui.notify = (message: string, type?: string) => notes.push({ message, type });
1173
+ ctx.ui.input = async () => "";
1174
+ // A sticky browser re-login yields the SAME ChatGPT account already backing the default slot.
1175
+ rotationModule.__setLoginForProviderForTest(async () => ({ refresh: "r2", access: "a2", expires: Date.now() + 60_000, accountId: "acct-default" }));
1176
+ try {
1177
+ await providerRotationExtension(pi);
1178
+
1179
+ await commands["provider-sub"].handler("openai-codex add codex-dup", ctx);
1180
+ assert.ok(notes.some((n) => n.type === "error" && /already added/i.test(n.message)), "re-adding the default account under a new label is rejected");
1181
+ pool = helpers.readSubscriptionPool("openai-codex");
1182
+ assert.deepEqual(pool.map((e) => e.label), ["openai-codex"]);
1183
+
1184
+ notes.length = 0;
1185
+ await commands["provider-sub"].handler("openai-codex list", ctx);
1186
+ assert.match(notes.map((n) => n.message).join("\n"), /account=/);
1187
+ } finally {
1188
+ rotationModule.__setLoginForProviderForTest(undefined);
1189
+ }
1190
+ });
1191
+
1192
+ // Drive the REAL default-login handler path: a collision with an explicit account must reject
1193
+ // before commitDefaultIdentity writes auth.json, so the failed OAuth result never partially
1194
+ // switches the default credential or creates a default pool entry with the duplicate identity.
1195
+ test("default-login handler rejects a colliding Codex account before switching auth.json", async () => {
1196
+ resetRotationState();
1197
+ helpers.addSubscription("openai-codex", credential("work", { accountId: "acct-X" }));
1198
+ const authPath = join(home, ".pi", "agent", "auth.json");
1199
+ const { ctx, commands, pi } = makeRotationCtx();
1200
+ ctx.hasUI = true;
1201
+ const notes: Array<{ message: string; type?: string }> = [];
1202
+ ctx.ui.notify = (message: string, type?: string) => notes.push({ message, type });
1203
+ ctx.ui.input = async () => "";
1204
+ rotationModule.__setLoginForProviderForTest(async () => ({ refresh: "r-X", access: "a-X", expires: Date.now() + 60_000, accountId: "acct-X" }));
1205
+ try {
1206
+ await providerRotationExtension(pi);
1207
+ await commands["provider-sub"].handler("openai-codex add openai-codex", ctx);
1208
+ assert.ok(notes.some((note) => note.type === "error" && /already added/i.test(note.message)));
1209
+ assert.equal(JSON.parse(readFileSync(authPath, "utf8"))["openai-codex"], undefined, "rejected default login must not switch auth.json");
1210
+ const pool = helpers.readSubscriptionPool("openai-codex");
1211
+ assert.equal(pool.filter((entry) => entry.accountId === "acct-X").length, 1);
1212
+ assert.equal(pool.some((entry) => entry.label === "openai-codex"), false);
1213
+ } finally {
1214
+ rotationModule.__setLoginForProviderForTest(undefined);
1215
+ }
1216
+ });
1217
+
1218
+ // The handler snapshots the default slot before OAuth. A 429 recorded while OAuth is open is
1219
+ // causally newer, so completing a same-account login must not reopen that quota.
1220
+ test("default-login handler preserves a newer same-account cooldown recorded during OAuth", async () => {
1221
+ resetRotationState();
1222
+ const authPath = join(home, ".pi", "agent", "auth.json");
1223
+ mkdirSync(dirname(authPath), { recursive: true });
1224
+ writeFileSync(
1225
+ authPath,
1226
+ `${JSON.stringify({ "openai-codex": { type: "oauth", refresh: "r-old", access: "a-old", expires: Date.now() + 60_000, accountId: "acct-default" } })}\n`,
1227
+ "utf8",
1228
+ );
1229
+ helpers.readSubscriptionPool("openai-codex");
1230
+
1231
+ const { ctx, commands, pi } = makeRotationCtx();
1232
+ ctx.hasUI = true;
1233
+ ctx.ui.notify = () => {};
1234
+ ctx.ui.input = async () => "";
1235
+ let rateLimitedAt = 0;
1236
+ rotationModule.__setLoginForProviderForTest(async () => {
1237
+ rateLimitedAt = Date.now();
1238
+ helpers.markSubscriptionRateLimited("openai-codex", "openai-codex", 60_000, rateLimitedAt, rateLimitedAt);
1239
+ return { refresh: "r-new", access: "a-new", expires: Date.now() + 60_000, accountId: "acct-default" };
1240
+ });
1241
+ try {
1242
+ await providerRotationExtension(pi);
1243
+ await commands["provider-sub"].handler("openai-codex add openai-codex", ctx);
1244
+ const [entry] = helpers.readSubscriptionPool("openai-codex");
1245
+ assert.equal(entry.accountId, "acct-default");
1246
+ assert.equal(entry.lastRateLimitedAt, rateLimitedAt);
1247
+ assert.equal(entry.rateLimitedUntil, rateLimitedAt + 60_000);
1248
+ assert.equal(JSON.parse(readFileSync(authPath, "utf8"))["openai-codex"].access, "a-new");
1249
+ } finally {
1250
+ rotationModule.__setLoginForProviderForTest(undefined);
1251
+ }
1252
+ });
1253
+
1254
+ // auth.json is the default slot's identity source of truth: every read reconciles the existing
1255
+ // metadata entry rather than only seeding it once. The pool never receives its token fields.
1256
+ test("the default slot re-syncs its identity from auth.json on every pool read", () => {
1257
+ resetRotationState();
1258
+ const authPath = join(home, ".pi", "agent", "auth.json");
1259
+ mkdirSync(dirname(authPath), { recursive: true });
1260
+ writeFileSync(
1261
+ authPath,
1262
+ `${JSON.stringify({ "openai-codex": { type: "oauth", refresh: "r-old", access: "a-old", expires: Date.now() + 60_000, accountId: "acct-old" } })}\n`,
1263
+ "utf8",
1264
+ );
1265
+ let [entry] = helpers.readSubscriptionPool("openai-codex");
1266
+ assert.equal(entry.accountId, "acct-old");
1267
+ assert.equal(entry.refresh, undefined);
1268
+ assert.equal(entry.access, undefined);
1269
+ assert.equal(entry.expires, undefined);
1270
+
1271
+ writeFileSync(
1272
+ authPath,
1273
+ `${JSON.stringify({ "openai-codex": { type: "oauth", refresh: "r-new", access: "a-new", expires: Date.now() + 60_000, accountId: "acct-new" } })}\n`,
1274
+ "utf8",
1275
+ );
1276
+ [entry] = helpers.readSubscriptionPool("openai-codex");
1277
+ assert.equal(entry.accountId, "acct-new");
1278
+ assert.equal(entry.refresh, undefined);
1279
+ assert.equal(entry.access, undefined);
1280
+ assert.equal(entry.expires, undefined);
1281
+ });
1282
+
1283
+ // An auth writer outside provider rotation can change the default identity without the pool
1284
+ // lock. Reconciliation validates under that lock and refuses to return or persist two slots for
1285
+ // one account; removing the explicit duplicate remains a valid repair operation.
1286
+ test("auth-driven default reconciliation refuses a colliding explicit identity", async () => {
1287
+ resetRotationState();
1288
+ const authPath = join(home, ".pi", "agent", "auth.json");
1289
+ const poolPath = join(home, ".pi", "agent", "openai-codex-subscriptions.json");
1290
+ mkdirSync(dirname(authPath), { recursive: true });
1291
+ writeFileSync(
1292
+ authPath,
1293
+ `${JSON.stringify({ "openai-codex": { type: "oauth", refresh: "r-old", access: "a-old", expires: 100, accountId: "acct-old" } })}\n`,
1294
+ "utf8",
1295
+ );
1296
+ helpers.readSubscriptionPool("openai-codex");
1297
+ helpers.addSubscription("openai-codex", credential("work", { accountId: "acct-new" }));
1298
+
1299
+ writeFileSync(
1300
+ authPath,
1301
+ `${JSON.stringify({ "openai-codex": { type: "oauth", refresh: "r-new", access: "a-new", expires: 200, accountId: "acct-new" } })}\n`,
1302
+ "utf8",
1303
+ );
1304
+ assert.throws(
1305
+ () => helpers.readSubscriptionPool("openai-codex"),
1306
+ (err: unknown) => err instanceof helpers.DuplicateSubscriptionError && /already added/i.test((err as Error).message),
1307
+ );
1308
+ const rawAfterRejection = JSON.parse(readFileSync(poolPath, "utf8"));
1309
+ assert.equal(rawAfterRejection.find((entry: { label: string }) => entry.label === "openai-codex").accountId, "acct-old");
1310
+
1311
+ const { ctx, commands, pi } = makeRotationCtx();
1312
+ ctx.ui.notify = () => {};
1313
+ await providerRotationExtension(pi);
1314
+ await commands["provider-sub"].handler("openai-codex rm work", ctx);
1315
+ assert.deepEqual(
1316
+ helpers.readSubscriptionPool("openai-codex").map((entry) => ({ label: entry.label, accountId: entry.accountId })),
1317
+ [{ label: "openai-codex", accountId: "acct-new" }],
1318
+ );
1319
+ });
1320
+
1321
+ // Explicit reauth uses the identity-checked upsert: it can refresh its OWN stored identity, but
1322
+ // cannot reauth into an account held by another slot.
1323
+ test("the uniqueness-aware reauth upsert refreshes its slot but rejects another slot's account", () => {
1324
+ resetRotationState();
1325
+ helpers.writeSubscriptionPool("openai-codex", [credential("work", { accountId: "acct-old", rateLimitedUntil: 900, lastAttemptAt: 100, lastRateLimitedAt: 50 }), credential("other", { accountId: "acct-held" })]);
1326
+ helpers.upsertSubscriptionWithUniqueAccount("openai-codex", credential("work", { accountId: "acct-new", refresh: "r-new", access: "a-new" }));
1327
+ let pool = helpers.readSubscriptionPool("openai-codex");
1328
+ const refreshed = pool.find((entry) => entry.label === "work");
1329
+ assert.equal(refreshed?.accountId, "acct-new");
1330
+ assert.equal(refreshed?.rateLimitedUntil, 900);
1331
+ assert.equal(refreshed?.lastAttemptAt, 100);
1332
+ assert.equal(refreshed?.lastRateLimitedAt, 50);
1333
+ assert.throws(
1334
+ () => helpers.upsertSubscriptionWithUniqueAccount("openai-codex", credential("work", { accountId: "acct-held", refresh: "r-collision", access: "a-collision" })),
1335
+ (err: unknown) => err instanceof helpers.DuplicateSubscriptionError && /already added/i.test((err as Error).message),
1336
+ );
1337
+ pool = helpers.readSubscriptionPool("openai-codex");
1338
+ assert.equal(pool.find((entry) => entry.label === "work")?.accountId, "acct-new");
1339
+ assert.equal(pool.filter((entry) => entry.accountId === "acct-held").length, 1);
1340
+ });
1341
+
1342
+ // A rejected default identity move must leave BOTH stores untouched: account uniqueness happens
1343
+ // inside the pool lock before commitDefaultIdentity writes auth.json under the nested auth lock.
1344
+ test("commitDefaultIdentity rejects an explicit account without partially switching auth.json or the pool", () => {
1345
+ resetRotationState();
1346
+ const authPath = join(home, ".pi", "agent", "auth.json");
1347
+ mkdirSync(dirname(authPath), { recursive: true });
1348
+ writeFileSync(
1349
+ authPath,
1350
+ `${JSON.stringify({ "openai-codex": { type: "oauth", refresh: "r-old", access: "a-old", expires: 100, accountId: "acct-old" } })}\n`,
1351
+ "utf8",
1352
+ );
1353
+ helpers.writeSubscriptionPool("openai-codex", [
1354
+ { label: "openai-codex", accountId: "acct-old", rateLimitedUntil: 0, lastAttemptAt: 10, lastRateLimitedAt: 0 },
1355
+ credential("work", { accountId: "acct-new" }),
1356
+ ]);
1357
+ const authBefore = readFileSync(authPath, "utf8");
1358
+ const poolBefore = helpers.readSubscriptionPool("openai-codex");
1359
+ assert.throws(
1360
+ () =>
1361
+ helpers.commitDefaultIdentity("openai-codex", {
1362
+ refresh: "r-new",
1363
+ access: "a-new",
1364
+ expires: 200,
1365
+ accountId: "acct-new",
1366
+ lastRateLimitedAt: 0,
1367
+ }),
1368
+ (err: unknown) => err instanceof helpers.DuplicateSubscriptionError && /already added/i.test((err as Error).message),
1369
+ );
1370
+ assert.equal(readFileSync(authPath, "utf8"), authBefore);
1371
+ assert.deepEqual(helpers.readSubscriptionPool("openai-codex"), poolBefore);
1372
+ });
1373
+
1374
+ // A same-account login uses its pre-OAuth rate-limit timestamp as a causal basis. A newer 429
1375
+ // survives, an equal-basis login may clear it, and a genuine account switch starts new quota clean.
1376
+ test("commitDefaultIdentity preserves newer same-account cooldowns and resets old-account cooldowns", () => {
1377
+ resetRotationState();
1378
+ const authPath = join(home, ".pi", "agent", "auth.json");
1379
+ mkdirSync(dirname(authPath), { recursive: true });
1380
+ writeFileSync(
1381
+ authPath,
1382
+ `${JSON.stringify({ "openai-codex": { type: "oauth", refresh: "r-old", access: "a-old", expires: 100, accountId: "acct-old" } })}\n`,
1383
+ "utf8",
1384
+ );
1385
+ helpers.readSubscriptionPool("openai-codex");
1386
+ helpers.markSubscriptionRateLimited("openai-codex", "openai-codex", 60_000, 100, 100);
1387
+
1388
+ helpers.commitDefaultIdentity("openai-codex", {
1389
+ refresh: "r-stale",
1390
+ access: "a-stale",
1391
+ expires: 200,
1392
+ accountId: "acct-old",
1393
+ lastRateLimitedAt: 0,
1394
+ });
1395
+ let [entry] = helpers.readSubscriptionPool("openai-codex");
1396
+ assert.equal(entry.lastRateLimitedAt, 100);
1397
+ assert.equal(entry.rateLimitedUntil, 60_100, "a newer same-account 429 survives the stale login");
1398
+
1399
+ helpers.commitDefaultIdentity("openai-codex", {
1400
+ refresh: "r-current",
1401
+ access: "a-current",
1402
+ expires: 300,
1403
+ accountId: "acct-old",
1404
+ lastRateLimitedAt: 100,
1405
+ });
1406
+ [entry] = helpers.readSubscriptionPool("openai-codex");
1407
+ assert.equal(entry.rateLimitedUntil, 0, "an equal-basis same-account login may clear the cooldown");
1408
+
1409
+ helpers.markSubscriptionRateLimited("openai-codex", "openai-codex", 60_000, 200, 200);
1410
+ helpers.commitDefaultIdentity("openai-codex", {
1411
+ refresh: "r-other",
1412
+ access: "a-other",
1413
+ expires: 400,
1414
+ accountId: "acct-other",
1415
+ lastRateLimitedAt: 100,
1416
+ });
1417
+ [entry] = helpers.readSubscriptionPool("openai-codex");
1418
+ assert.equal(entry.accountId, "acct-other");
1419
+ assert.equal(entry.lastRateLimitedAt, 200);
1420
+ assert.equal(entry.rateLimitedUntil, 0, "a different account does not inherit the old account's quota cooldown");
1421
+ });
1422
+
1423
+ // Label AND account uniqueness are enforced INSIDE the locked add transform
1424
+ // (throwing a typed error), so the check-then-append is atomic rather than a lock-free race.
1425
+ test("addSubscription normalizes labels and enforces Codex account uniqueness inside the locked transform", () => {
1426
+ resetRotationState();
1427
+ helpers.addSubscription("openai-codex", credential("work", { accountId: "acct-1" }));
1428
+ assert.throws(
1429
+ () => helpers.addSubscription("openai-codex", credential(" work ", { accountId: "acct-2" })),
1430
+ (err: unknown) => err instanceof helpers.DuplicateSubscriptionError && /already exists/i.test((err as Error).message),
1431
+ );
1432
+ assert.throws(
1433
+ () => helpers.addSubscription("openai-codex", credential(" ", { accountId: "acct-3" })),
1434
+ (err: unknown) => !(err instanceof helpers.DuplicateSubscriptionError) && /cannot be empty/i.test((err as Error).message),
1435
+ );
1436
+ assert.throws(
1437
+ () => helpers.addSubscription("openai-codex", credential("work-2", { accountId: "acct-1" })),
1438
+ (err: unknown) => err instanceof helpers.DuplicateSubscriptionError && /already added/i.test((err as Error).message),
1439
+ );
1440
+ helpers.addSubscription("openai-codex", credential("work-3", { accountId: "acct-3" }));
1441
+ assert.deepEqual(helpers.readSubscriptionPool("openai-codex").map((entry) => entry.label).sort(), ["work", "work-3"]);
1442
+ });
1443
+
1444
+ // The first contender holds the real pool lock inside addSubscription's transform. The second
1445
+ // contender cannot reach its own transform until it has first attempted that same real lock.
1446
+ // This makes an outside-lock uniqueness check deterministic: both checks run while the pool is
1447
+ // empty, then both appends succeed after the holder releases.
1448
+ test("cross-process explicit adds admit exactly one duplicate Codex account", async () => {
1449
+ resetRotationState();
1450
+ const poolPath = join(home, ".pi", "agent", "openai-codex-subscriptions.json");
1451
+ const releasePath = join(home, "release-cross-process-add-race");
1452
+ const holderInsidePath = join(home, "holder-inside-cross-process-add-race");
1453
+ const subscriptionStateUrl = new URL("../../lib/subscription-state.ts", import.meta.url).href;
1454
+ const spawnContender = (label: string, role: "holder" | "waiter") => {
1455
+ const child = spawn(
1456
+ process.execPath,
1457
+ [
1458
+ "--import",
1459
+ "tsx/esm",
1460
+ "--input-type=module",
1461
+ "-e",
1462
+ String.raw`import { existsSync, writeSync, writeFileSync } from "node:fs";
1463
+ import { FileAuthStorageBackend } from "@earendil-works/pi-coding-agent";
1464
+ const { addSubscription, DuplicateSubscriptionError } = await import(${JSON.stringify(subscriptionStateUrl)});
1465
+ const poolPath = ${JSON.stringify(poolPath)};
1466
+ const releasePath = ${JSON.stringify(releasePath)};
1467
+ const holderInsidePath = ${JSON.stringify(holderInsidePath)};
1468
+ const role = ${JSON.stringify(role)};
1469
+ // Yield the CPU while polling for a coordination file instead of a tight busy-spin. Two
1470
+ // contenders spinning at 100% CPU starve the parent's timeout observers on an
1471
+ // oversubscribed CI runner, which is one way this cross-process test flaked in CI while
1472
+ // passing locally. Atomics.wait blocks the thread for the poll interval without burning a
1473
+ // core -- the semantics (proceed the instant the file appears) are unchanged.
1474
+ const waitForFile = (path) => {
1475
+ const idle = new Int32Array(new SharedArrayBuffer(4));
1476
+ while (!existsSync(path)) Atomics.wait(idle, 0, 0, 5);
1477
+ };
1478
+ const originalWithLock = FileAuthStorageBackend.prototype.withLock;
1479
+ FileAuthStorageBackend.prototype.withLock = function (fn) {
1480
+ if (this.authPath !== poolPath) return originalWithLock.call(this, fn);
1481
+ writeSync(1, "attempt\n");
1482
+ if (role === "waiter") {
1483
+ // This is a real acquisition attempt against the holder's real lock. It happens after any
1484
+ // hypothetical outside-lock validation but before the transform can run.
1485
+ try {
1486
+ originalWithLock.call(new FileAuthStorageBackend(poolPath), () => ({ result: undefined }));
1487
+ throw new Error("waiter acquired the pool lock while the holder was still inside it");
1488
+ } catch (error) {
1489
+ if ((error).code !== "ELOCKED") throw error;
1490
+ }
1491
+ writeSync(1, "acquiring\n");
1492
+ waitForFile(releasePath);
1493
+ }
1494
+ return originalWithLock.call(this, (current) => {
1495
+ if (role === "holder") {
1496
+ writeSync(1, "inside\n");
1497
+ writeFileSync(holderInsidePath, "inside\n");
1498
+ waitForFile(releasePath);
1499
+ }
1500
+ return fn(current);
1501
+ });
1502
+ };
1503
+ // The holder's wait-for-release runs inside a SYNC withLock hold, which blocks its event
1504
+ // loop -- so the lock library's stale-refresh timer cannot fire and the lock's mtime ages.
1505
+ // If that sync hold spans this waiter's multi-second tsx boot on a slow CI runner, the
1506
+ // lock crosses the stale threshold and the waiter's probe STEALS it instead of hitting
1507
+ // ELOCKED (the exact full-tier CI failure). Both contenders therefore boot in parallel,
1508
+ // and the waiter gates HERE -- after its own boot, before touching the pool -- on the
1509
+ // holder being inside the lock, keeping the holder's sync hold to the sub-second
1510
+ // coordination tail, far under the stale window.
1511
+ if (role === "waiter") waitForFile(holderInsidePath);
1512
+ try {
1513
+ addSubscription("openai-codex", {
1514
+ label: ${JSON.stringify(label)}, refresh: "refresh-${label}", access: "access-${label}", expires: Date.now() + 60_000,
1515
+ accountId: "acct-cross-process-race", rateLimitedUntil: 0, lastAttemptAt: 0, lastRateLimitedAt: 0,
1516
+ });
1517
+ writeSync(1, "outcome:success\n");
1518
+ } catch (error) {
1519
+ const outcome = error instanceof DuplicateSubscriptionError ? "duplicate" : "error";
1520
+ writeSync(1, "outcome:" + outcome + "\n");
1521
+ if (outcome === "error") process.stderr.write(String(error));
1522
+ }`,
1523
+ ],
1524
+ { env: { ...process.env, HOME: home }, stdio: ["pipe", "pipe", "pipe"] },
1525
+ );
1526
+ const signals = new Set<string>();
1527
+ let outcome: string | undefined;
1528
+ let stderr = "";
1529
+ let stdout = "";
1530
+ child.stdout!.on("data", (chunk: string) => {
1531
+ stdout += chunk;
1532
+ });
1533
+ child.stderr!.setEncoding("utf8");
1534
+ child.stderr!.on("data", (chunk: string) => {
1535
+ stderr += chunk;
1536
+ });
1537
+ const signal = (expected: string) =>
1538
+ new Promise<void>((resolve, reject) => {
1539
+ child.once("error", reject);
1540
+ child.once("close", (code) => {
1541
+ if (!signals.has(expected)) reject(new Error(`${label} exited with ${code} before ${expected}: ${stderr.trim()}`));
1542
+ });
1543
+ observeLines(child.stdout!, (line) => {
1544
+ signals.add(line);
1545
+ if (line === expected) resolve();
1546
+ if (line.startsWith("outcome:")) outcome = line.slice("outcome:".length);
1547
+ });
1548
+ });
1549
+ const closed = new Promise<number | null>((resolve) => child.once("close", resolve));
1550
+ const stdoutEnded = new Promise<void>((resolve) => child.stdout!.once("end", resolve));
1551
+ return { child, closed, label, outcome: () => outcome, signal, stderr: () => stderr, stdout: () => stdout, stdoutEnded };
1552
+ };
1553
+
1554
+ // Spawn both contenders up front so their expensive tsx boots overlap and neither boot
1555
+ // happens while the holder is sitting inside its sync lock hold (see the child comment on
1556
+ // lock staleness). The waiter self-gates on holderInsidePath, so the probe ordering --
1557
+ // waiter attempts the lock only while the holder is inside it -- is unchanged.
1558
+ const holder = spawnContender("race-left", "holder");
1559
+ const waiter = spawnContender("race-right", "waiter");
1560
+ const contenders = [holder, waiter];
1561
+ try {
1562
+ // Boot budgets, not semantics guards: each contender is a fresh `node --import tsx/esm`
1563
+ // process that compiles the TS module graph before it can signal. Under the full tier's
1564
+ // --test-concurrency=4 on a 4-vCPU CI runner that boot alone can exceed 10s, so give the
1565
+ // coordination phases real headroom -- a genuine lock bug shows up as a wrong outcome in
1566
+ // the assertions below, never as a slow boot.
1567
+ await within(Promise.all([holder.signal("attempt"), holder.signal("inside")]), 60_000, "holder did not reach the locked transform within 60s");
1568
+ await within(Promise.all([waiter.signal("attempt"), waiter.signal("acquiring")]), 60_000, "waiter did not attempt the held pool lock within 60s");
1569
+ writeFileSync(releasePath, "release\n", "utf8");
1570
+ const exitCodes = await within(Promise.all(contenders.map((contender) => contender.closed)), 30_000, "cross-process contenders did not exit within 30s");
1571
+ await within(Promise.all(contenders.map((contender) => contender.stdoutEnded)), 15_000, "cross-process contender output did not close within 15s");
1572
+ assert.deepEqual(exitCodes, [0, 0], contenders.map((contender) => contender.stderr()).join("\n"));
1573
+ assert.deepEqual(contenders.map((contender) => contender.outcome()).sort(), ["duplicate", "success"], contenders.map((contender) => contender.stdout()).join("\n"));
1574
+ const pool = helpers.readSubscriptionPool("openai-codex");
1575
+ assert.equal(pool.length, 1);
1576
+ assert.ok(["race-left", "race-right"].includes(pool[0].label));
1577
+ } finally {
1578
+ const errors: unknown[] = [];
1579
+ try {
1580
+ // Unblock a waiter still gated on the holder-inside file as well as anyone waiting on
1581
+ // release, so an early assertion failure cannot leave a child polling forever.
1582
+ writeFileSync(holderInsidePath, "inside\n", "utf8");
1583
+ writeFileSync(releasePath, "release\n", "utf8");
1584
+ } catch (error) {
1585
+ errors.push(error);
1586
+ }
1587
+ const teardown = async (contender: ReturnType<typeof spawnContender>) => {
1588
+ if (contender.child.exitCode !== null || contender.child.signalCode !== null) return;
1589
+ try {
1590
+ contender.child.stdin!.end();
1591
+ } catch {
1592
+ // The child may already have closed its input.
1593
+ }
1594
+ if (await within(contender.closed.then(() => true), 1000, `${contender.label} did not exit gracefully within 1s`).catch(() => false)) return;
1595
+ contender.child.kill("SIGTERM");
1596
+ if (await within(contender.closed.then(() => true), 1000, `${contender.label} did not exit after SIGTERM`).catch(() => false)) return;
1597
+ contender.child.kill("SIGKILL");
1598
+ await within(contender.closed, 1000, `${contender.label} did not exit after SIGKILL`);
1599
+ };
1600
+ const results = await Promise.allSettled(contenders.map(teardown));
1601
+ errors.push(...results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])));
1602
+ try {
1603
+ assert.equal(existsSync(`${poolPath}.lock`), false, "cross-process add race must release the pool lock directory");
1604
+ } catch (error) {
1605
+ errors.push(error);
1606
+ }
1607
+ if (errors.length) throw new AggregateError(errors, "cross-process contender teardown failed");
1608
+ }
1609
+ });
1610
+
1611
+ // Corruption can arrive after commitDefaultIdentity projects auth.json for its pool-lock
1612
+ // callback but before it obtains the nested auth lock to persist. That persistence parse must
1613
+ // fail loudly and leave both stores precisely as they were at the corruption point.
1614
+ test("malformed auth during nested default persistence preserves malformed bytes and the pool cooldown", () => {
1615
+ resetRotationState();
1616
+ const authPath = join(home, ".pi", "agent", "auth.json");
1617
+ const poolPath = join(home, ".pi", "agent", "openai-codex-subscriptions.json");
1618
+ mkdirSync(dirname(authPath), { recursive: true });
1619
+ writeFileSync(
1620
+ authPath,
1621
+ `${JSON.stringify({ "openai-codex": { type: "oauth", refresh: "r-old", access: "a-old", expires: 100, accountId: "acct-old" } })}\n`,
1622
+ "utf8",
1623
+ );
1624
+ helpers.readSubscriptionPool("openai-codex");
1625
+ helpers.markSubscriptionRateLimited("openai-codex", "openai-codex", 60_000, 500, 500);
1626
+ const poolBefore = readFileSync(poolPath, "utf8");
1627
+ const malformed = '{"openai-codex":';
1628
+ const originalWithLock = FileAuthStorageBackend.prototype.withLock;
1629
+ let authLockCalls = 0;
1630
+ FileAuthStorageBackend.prototype.withLock = function (fn) {
1631
+ const isAuthLock = (this as unknown as { authPath: string }).authPath === authPath;
1632
+ if (isAuthLock) authLockCalls += 1;
1633
+ const result = originalWithLock.call(this, fn);
1634
+ if (isAuthLock && authLockCalls === 1) writeFileSync(authPath, malformed, "utf8");
1635
+ return result;
1636
+ };
1637
+ try {
1638
+ assert.throws(
1639
+ () =>
1640
+ helpers.commitDefaultIdentity("openai-codex", {
1641
+ refresh: "r-new",
1642
+ access: "a-new",
1643
+ expires: 200,
1644
+ accountId: "acct-old",
1645
+ lastRateLimitedAt: 500,
1646
+ }),
1647
+ SyntaxError,
1648
+ );
1649
+ } finally {
1650
+ FileAuthStorageBackend.prototype.withLock = originalWithLock;
1651
+ }
1652
+ assert.equal(authLockCalls, 2, "the second auth lock is the nested persistence parse");
1653
+ assert.equal(readFileSync(authPath, "utf8"), malformed);
1654
+ assert.equal(readFileSync(poolPath, "utf8"), poolBefore);
1655
+ const [entry] = JSON.parse(poolBefore) as helpers.SubscriptionCredential[];
1656
+ assert.deepEqual(
1657
+ { lastRateLimitedAt: entry.lastRateLimitedAt, rateLimitedUntil: entry.rateLimitedUntil },
1658
+ { lastRateLimitedAt: 500, rateLimitedUntil: 60_500 },
1659
+ );
1660
+ });
1661
+
1662
+ test("renames explicit subscriptions without permitting default-slot or case-insensitive collisions", () => {
1663
+ resetRotationState();
1664
+ helpers.writeSubscriptionPool("openai-codex", [credential("openai-codex"), credential("work"), credential("personal")]);
1665
+ assert.deepEqual(helpers.renameSubscription("openai-codex", "work", "Lab").map((entry) => entry.label), ["openai-codex", "Lab", "personal"]);
1666
+ assert.throws(() => helpers.renameSubscription("openai-codex", "Lab", " PERSONAL "), helpers.DuplicateSubscriptionError);
1667
+ assert.throws(() => helpers.renameSubscription("openai-codex", "openai-codex", "anything"), /Default account/);
1668
+ });
1669
+
838
1670
  test("treats numeric subscription refs as 1-based indexes", () => {
839
1671
  resetRotationState();
840
1672
  const pool = [credential("alpha"), credential("beta")];
@@ -1344,6 +2176,106 @@ test("automatically reauthenticates invalid refresh tokens when UI is available"
1344
2176
  }
1345
2177
  });
1346
2178
 
2179
+ // The real invalid-refresh path must route an explicit Codex reauth through the locked identity
2180
+ // gate. A colliding OAuth result is rejected, the old credential remains intact, and the slot is
2181
+ // cooled instead of being duplicated or streamed with another slot's quota.
2182
+ test("real explicit Codex reauth rejects an account held by another slot", async () => {
2183
+ resetRotationState();
2184
+ writeLadderConfig();
2185
+ helpers.writeSubscriptionPool("openai-codex", [
2186
+ credential("stale", { expires: 1, accountId: "acct-old" }),
2187
+ credential("held", { accountId: "acct-held", rateLimitedUntil: Date.now() + 10 * 60_000 }),
2188
+ ]);
2189
+
2190
+ const { ctx, providers, events, pi } = makeRotationCtx();
2191
+ ctx.hasUI = true;
2192
+ const notes: Array<{ message: string; type?: string }> = [];
2193
+ ctx.ui.notify = (message: string, type?: string) => notes.push({ message, type });
2194
+ rotationModule.__setRefreshForProviderForTest(async (providerId) => {
2195
+ if (providerId === "openai-codex") {
2196
+ throw new Error('HTTP request failed. status=400; body={"error":"invalid_grant","error_description":"Refresh token not found or invalid"}');
2197
+ }
2198
+ throw new Error("unexpected refresh");
2199
+ });
2200
+ rotationModule.__setLoginForProviderForTest(async () => ({
2201
+ refresh: "r-collision",
2202
+ access: "a-collision",
2203
+ expires: Date.now() + 60_000,
2204
+ accountId: "acct-held",
2205
+ }));
2206
+ try {
2207
+ await providerRotationExtension(pi);
2208
+ await events.session_start?.({}, ctx);
2209
+ const stream = providers["openai-codex"].streamSimple(ctx.model, ctx);
2210
+ for await (const _event of stream) {
2211
+ // Drain the real managed-provider stream to completion.
2212
+ }
2213
+
2214
+ const pool = helpers.readSubscriptionPool("openai-codex");
2215
+ const stale = pool.find((entry) => entry.label === "stale");
2216
+ assert.equal(stale?.accountId, "acct-old");
2217
+ assert.equal(stale?.refresh, "refresh-stale");
2218
+ assert.ok((stale?.rateLimitedUntil ?? 0) > Date.now() + 29 * 24 * 60 * 60 * 1000);
2219
+ assert.equal(pool.filter((entry) => entry.accountId === "acct-held").length, 1);
2220
+ assert.ok(notes.some((note) => note.type === "error" && /re-authentication failed.*already added/i.test(note.message)));
2221
+ } finally {
2222
+ rotationModule.__setRefreshForProviderForTest(undefined);
2223
+ rotationModule.__setLoginForProviderForTest(undefined);
2224
+ }
2225
+ });
2226
+
2227
+ // The default Codex reauth branch uses the same gate before auth persistence. A colliding login
2228
+ // leaves auth.json on the original account and keeps both pool identities unique.
2229
+ test("real default Codex reauth rejects an explicit slot's account before switching auth.json", async () => {
2230
+ resetRotationState();
2231
+ writeLadderConfig();
2232
+ const authPath = join(home, ".pi", "agent", "auth.json");
2233
+ mkdirSync(dirname(authPath), { recursive: true });
2234
+ writeFileSync(
2235
+ authPath,
2236
+ `${JSON.stringify({ "openai-codex": { type: "oauth", refresh: "dead-refresh", access: "stale-access", expires: 1, accountId: "acct-old" } })}\n`,
2237
+ "utf8",
2238
+ );
2239
+ helpers.readSubscriptionPool("openai-codex");
2240
+ helpers.addSubscription("openai-codex", credential("held", { accountId: "acct-held", rateLimitedUntil: Date.now() + 10 * 60_000 }));
2241
+
2242
+ const { ctx, providers, events, pi } = makeRotationCtx();
2243
+ ctx.hasUI = true;
2244
+ const notes: Array<{ message: string; type?: string }> = [];
2245
+ ctx.ui.notify = (message: string, type?: string) => notes.push({ message, type });
2246
+ rotationModule.__setRefreshForProviderForTest(async (providerId) => {
2247
+ if (providerId === "openai-codex") {
2248
+ throw new Error('HTTP request failed. status=400; body={"error":"invalid_grant","error_description":"Refresh token not found or invalid"}');
2249
+ }
2250
+ throw new Error("unexpected refresh");
2251
+ });
2252
+ rotationModule.__setLoginForProviderForTest(async () => ({
2253
+ refresh: "r-collision",
2254
+ access: "a-collision",
2255
+ expires: Date.now() + 60_000,
2256
+ accountId: "acct-held",
2257
+ }));
2258
+ try {
2259
+ await providerRotationExtension(pi);
2260
+ await events.session_start?.({}, ctx);
2261
+ const stream = providers["openai-codex"].streamSimple(ctx.model, ctx);
2262
+ for await (const _event of stream) {
2263
+ // Drain the real managed-provider stream to completion.
2264
+ }
2265
+
2266
+ const auth = JSON.parse(readFileSync(authPath, "utf8"))["openai-codex"];
2267
+ assert.equal(auth.accountId, "acct-old");
2268
+ assert.equal(auth.refresh, "dead-refresh");
2269
+ const pool = helpers.readSubscriptionPool("openai-codex");
2270
+ assert.equal(pool.find((entry) => entry.label === "openai-codex")?.accountId, "acct-old");
2271
+ assert.equal(pool.filter((entry) => entry.accountId === "acct-held").length, 1);
2272
+ assert.ok(notes.some((note) => note.type === "error" && /re-authentication failed.*already added/i.test(note.message)));
2273
+ } finally {
2274
+ rotationModule.__setRefreshForProviderForTest(undefined);
2275
+ rotationModule.__setLoginForProviderForTest(undefined);
2276
+ }
2277
+ });
2278
+
1347
2279
  // A resolved-but-unauthenticated fallback stays on the current model and returns an
1348
2280
  // actionable "not authenticated" error when no provider is logged in.
1349
2281
  test("emits an actionable 'not authenticated' error without switching models when neither provider has ever logged in", async () => {
@@ -1515,6 +2447,97 @@ test("does not wait out a cooldown longer than the sole-provider wait cap; surfa
1515
2447
  }
1516
2448
  });
1517
2449
 
2450
+ // Live-incident regression (Hearth: a home's config.json had `gpt-5.6-*` ladder rungs baked
2451
+ // in while the installed guest's model registry only had `gpt-5.5`): when the ONLY viable
2452
+ // fallback target is authenticated (its pool has a credential) but its resolved model isn't
2453
+ // in this build's registry, the terminal error must name that specific reason -- not the old
2454
+ // generic "authenticate a fallback provider" text, which is actively wrong when the real
2455
+ // blocker is a stale/out-of-sync ladder, not a missing login.
2456
+ test("surfaces a specific 'not in model registry' reason instead of misleadingly telling the user to authenticate", async () => {
2457
+ resetRotationState();
2458
+ writeLadderConfig();
2459
+ helpers.writeSubscriptionPool("anthropic", [credential("primary")]);
2460
+ helpers.markSubscriptionRateLimited("anthropic", "primary", 30 * 24 * 60 * 60 * 1000);
2461
+ // OpenAI Codex IS authenticated -- the fallback rejection must NOT be "no credentials".
2462
+ helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
2463
+
2464
+ const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
2465
+ ctx.model = { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" };
2466
+ // Registry recognizes anthropic's own model but NOT the configured openai-codex fallback
2467
+ // rung (`gpt-5.6-sol`, from writeLadderConfig) -- simulating an installed guest whose
2468
+ // catalog hasn't caught up with the configured ladder.
2469
+ ctx.modelRegistry = {
2470
+ find(provider: string, id: string) {
2471
+ return provider === "anthropic" && id === "claude-opus-4-8" ? { provider, id, api: "anthropic-messages" } : undefined;
2472
+ },
2473
+ };
2474
+ const sleepCalls: number[] = [];
2475
+ rotationModule.__setSleepForTest(async (ms) => {
2476
+ sleepCalls.push(ms);
2477
+ });
2478
+
2479
+ try {
2480
+ await providerRotationExtension(pi);
2481
+ await events.session_start?.({}, ctx);
2482
+ const stream = providers["anthropic"].streamSimple(
2483
+ { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" },
2484
+ ctx,
2485
+ );
2486
+ const emitted = [] as any[];
2487
+ for await (const event of stream) emitted.push(event);
2488
+
2489
+ assert.deepEqual(setModelCalls, []);
2490
+ assert.deepEqual(sleepCalls, []);
2491
+ assert.equal(emitted.length, 1);
2492
+ assert.equal(emitted[0].type, "error");
2493
+ assert.match(emitted[0].error.errorMessage, /openai-codex\/gpt-5\.6-sol:high is not in this build's model registry/);
2494
+ assert.match(emitted[0].error.errorMessage, /modelLadders config is out of sync with the installed model catalog/);
2495
+ assert.doesNotMatch(emitted[0].error.errorMessage, /authenticate a fallback provider/);
2496
+ // The repair command must be EXECUTABLE as printed: `--value`, the CONFIG ladder
2497
+ // provider key (`openai`, not the runtime `openai-codex`), and the target's strength.
2498
+ assert.match(
2499
+ emitted[0].error.errorMessage,
2500
+ /crtr sys config set modelLadders\.openai\.strong --value <installed-model-id>/,
2501
+ );
2502
+ } finally {
2503
+ rotationModule.__setSleepForTest(undefined);
2504
+ }
2505
+ });
2506
+
2507
+ // Every fallback-rejection discriminant maps to advice that can actually resolve IT.
2508
+ // Only `no_credentials` may say "authenticate" -- telling the user to authenticate when both
2509
+ // providers are exhausted, when no ladder rung exists, or when the model is unregistered is
2510
+ // actively wrong and was the original bug.
2511
+ test("fallback rejection hints: only no_credentials advises authentication; every other discriminant gets accurate advice", () => {
2512
+ const codex = {
2513
+ providerId: "openai-codex",
2514
+ modelId: "gpt-5.6-sol",
2515
+ label: "openai-codex/gpt-5.6-sol:high",
2516
+ strength: "strong",
2517
+ } as helpers.FallbackTarget;
2518
+
2519
+ const noCreds = rotationModule.__fallbackRejectionHintForTest("no_credentials", codex);
2520
+ assert.match(noCreds, /authenticate a fallback provider/);
2521
+ assert.match(noCreds, /OpenAI Codex/);
2522
+ assert.match(noCreds, /\/provider-sub openai-codex add/);
2523
+
2524
+ const unregistered = rotationModule.__fallbackRejectionHintForTest("model_unregistered", codex);
2525
+ assert.doesNotMatch(unregistered, /authenticate a fallback provider/);
2526
+ assert.match(unregistered, /not in this build's model registry/);
2527
+ // Executable repair command: --value, CONFIG ladder key (openai), target strength.
2528
+ assert.match(unregistered, /crtr sys config set modelLadders\.openai\.strong --value <installed-model-id>/);
2529
+
2530
+ const alreadyTried = rotationModule.__fallbackRejectionHintForTest("already_attempted", codex);
2531
+ assert.doesNotMatch(alreadyTried, /authenticate a fallback provider/);
2532
+ assert.match(alreadyTried, /already tried this turn/);
2533
+ assert.match(alreadyTried, /try again later/);
2534
+
2535
+ const noLadder = rotationModule.__fallbackRejectionHintForTest("no_ladder_entry");
2536
+ assert.doesNotMatch(noLadder, /authenticate a fallback provider/);
2537
+ assert.match(noLadder, /no cross-provider fallback is configured/);
2538
+ assert.match(noLadder, /modelLadders/);
2539
+ });
2540
+
1518
2541
  // The wait-out decision uses the pool state from the current turn, not a stale snapshot.
1519
2542
  test("waits out a sole credential's cooldown observed in THIS turn, not a stale pre-attempt pool snapshot", async () => {
1520
2543
  resetRotationState();
@@ -1618,3 +2641,94 @@ test("treats a Codex usage-limit message on a non-429 status as a rate limit: co
1618
2641
  rotationModule.__setStreamForProviderForTest(undefined);
1619
2642
  }
1620
2643
  });
2644
+
2645
+ // --- Provider-management removal routing + login coordination (final review findings #1, #2) ---
2646
+
2647
+ function writeDefaultAuthRecord(providerId: "anthropic" | "openai-codex", extra: Record<string, unknown> = {}): void {
2648
+ const authPath = join(home, ".pi", "agent", "auth.json");
2649
+ mkdirSync(dirname(authPath), { recursive: true });
2650
+ writeFileSync(
2651
+ authPath,
2652
+ `${JSON.stringify({ [providerId]: { type: "oauth", refresh: "r", access: "a", expires: Date.now() + 60_000, ...extra } }, null, 2)}\n`,
2653
+ { encoding: "utf8", mode: 0o600 },
2654
+ );
2655
+ }
2656
+
2657
+ function readAuthRecord(providerId: string): unknown {
2658
+ const raw = readFileSync(join(home, ".pi", "agent", "auth.json"), "utf8");
2659
+ return (JSON.parse(raw) as Record<string, unknown>)[providerId];
2660
+ }
2661
+
2662
+ // The exact guard finding #1 tripped: removing a managed provider from its HEADER row (no
2663
+ // label -- the default cursor position) MUST route through the coordinated service and clear
2664
+ // BOTH auth.json and the rotation pool. The old code fell through to a raw AuthStorage.logout(),
2665
+ // which cleared only auth.json and left the explicit account configured while the panel said
2666
+ // "Removed" -- so the surviving pool entry below is what catches the regression.
2667
+ test("removeProviderRow on a managed HEADER row clears auth.json AND the whole pool (finding #1)", () => {
2668
+ resetRotationState();
2669
+ writeDefaultAuthRecord("anthropic");
2670
+ helpers.upsertSubscription("anthropic", credential("work"));
2671
+ assert.ok(helpers.readSubscriptionPool("anthropic").some((e) => e.label === "anthropic"), "default slot seeded from auth.json");
2672
+ assert.ok(helpers.readSubscriptionPool("anthropic").some((e) => e.label === "work"), "explicit account present");
2673
+
2674
+ const notice = providerManagement.removeProviderRow(join(home, ".pi", "agent"), {
2675
+ providerId: "anthropic",
2676
+ name: "Claude",
2677
+ managed: true,
2678
+ configured: true,
2679
+ // no label -> the provider header row
2680
+ });
2681
+
2682
+ assert.equal(notice, "Removed Claude");
2683
+ assert.deepEqual(helpers.readSubscriptionPool("anthropic"), [], "pool fully torn down (would still hold 'work' under the old logout() bug)");
2684
+ assert.equal(readAuthRecord("anthropic"), undefined, "auth.json default record cleared");
2685
+ });
2686
+
2687
+ // An account ROW (has a label) removes just that account and leaves the rest of the pool intact.
2688
+ test("removeProviderRow on a managed account row removes only that account", () => {
2689
+ resetRotationState();
2690
+ writeDefaultAuthRecord("anthropic");
2691
+ helpers.upsertSubscription("anthropic", credential("work"));
2692
+
2693
+ const notice = providerManagement.removeProviderRow(join(home, ".pi", "agent"), {
2694
+ providerId: "anthropic",
2695
+ name: "Claude",
2696
+ managed: true,
2697
+ label: "work",
2698
+ });
2699
+
2700
+ assert.equal(notice, "Removed Claude account");
2701
+ assert.deepEqual(
2702
+ helpers.readSubscriptionPool("anthropic").map((e) => e.label),
2703
+ ["anthropic"],
2704
+ "only the default slot remains",
2705
+ );
2706
+ assert.ok(readAuthRecord("anthropic"), "auth.json default record untouched");
2707
+ });
2708
+
2709
+ // /login coordination (finding #2): the default slot commits through commitDefaultIdentity, so
2710
+ // auth.json gets the oauth record AND the pool default slot reflects the identity as metadata only.
2711
+ test("commitManagedLogin default slot writes auth.json + metadata-only pool identity", () => {
2712
+ resetRotationState();
2713
+ helpers.commitManagedLogin("anthropic", "anthropic", { refresh: "r1", access: "a1", expires: Date.now() + 60_000, accountId: "acct-1" });
2714
+
2715
+ const record = readAuthRecord("anthropic") as { type?: string; accountId?: string } | undefined;
2716
+ assert.equal(record?.type, "oauth");
2717
+ assert.equal(record?.accountId, "acct-1");
2718
+
2719
+ const slot = helpers.readSubscriptionPool("anthropic").find((e) => e.label === "anthropic");
2720
+ assert.ok(slot, "default slot present");
2721
+ assert.equal(slot?.accountId, "acct-1", "pool default slot carries the identity");
2722
+ assert.equal(slot?.refresh, undefined, "pool default slot stays metadata-only (no token)");
2723
+ });
2724
+
2725
+ // The account-uniqueness gate the raw authStorage.login() path skipped: a second slot for the
2726
+ // same account (same accountId) is rejected.
2727
+ test("commitManagedLogin enforces account uniqueness the raw login path skipped", () => {
2728
+ resetRotationState();
2729
+ helpers.commitManagedLogin("openai-codex", "openai-codex", { refresh: "r", access: "a", expires: Date.now() + 60_000, accountId: "dup" });
2730
+ assert.throws(
2731
+ () => helpers.commitManagedLogin("openai-codex", "second", { refresh: "r2", access: "a2", expires: Date.now() + 60_000, accountId: "dup" }),
2732
+ /already added/,
2733
+ );
2734
+ });