@crouton-kit/crouter 0.3.68 → 0.3.78

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 (176) hide show
  1. package/README.md +1 -1
  2. package/dist/builtin-memory/00-runtime-base.md +3 -10
  3. package/dist/builtin-memory/04-base-worker.md +18 -0
  4. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
  6. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +639 -741
  7. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +241 -58
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +365 -94
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
  11. package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
  12. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
  13. package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
  14. package/dist/builtin-views/canvas/tui.mjs +8 -9
  15. package/dist/builtin-views/chat/tui.mjs +12 -12
  16. package/dist/builtin-views/git-pr/tui.mjs +7 -8
  17. package/dist/builtin-views/inbox/tui.mjs +27 -41
  18. package/dist/builtin-views/linkedin/tui.mjs +23 -37
  19. package/dist/builtin-views/prompt-review/tui.mjs +11 -11
  20. package/dist/builtin-views/settings/tui.mjs +11 -11
  21. package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
  22. package/dist/cli.d.ts +1 -1
  23. package/dist/cli.js +1 -0
  24. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +11 -7
  25. package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
  26. package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
  27. package/dist/clients/attach/__tests__/mermaid-render.test.js +20 -0
  28. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
  29. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
  30. package/dist/clients/attach/attach-cmd.d.ts +1 -1
  31. package/dist/clients/attach/attach-cmd.js +733 -730
  32. package/dist/clients/attach/auth-pickers.d.ts +12 -0
  33. package/dist/clients/attach/auth-pickers.js +11 -0
  34. package/dist/clients/attach/canvas-panels.js +2 -3
  35. package/dist/clients/attach/chat-view.d.ts +7 -8
  36. package/dist/clients/attach/chat-view.js +119 -79
  37. package/dist/clients/attach/edit-diff-render.d.ts +6 -0
  38. package/dist/clients/attach/edit-diff-render.js +263 -0
  39. package/dist/clients/attach/input-controller.d.ts +28 -2
  40. package/dist/clients/attach/input-controller.js +38 -5
  41. package/dist/clients/attach/mermaid-render.js +5 -2
  42. package/dist/clients/attach/pickers.d.ts +8 -7
  43. package/dist/clients/attach/pickers.js +13 -17
  44. package/dist/clients/attach/slash-commands.d.ts +9 -0
  45. package/dist/clients/attach/slash-commands.js +128 -7
  46. package/dist/clients/attach/titled-editor.d.ts +12 -1
  47. package/dist/clients/attach/titled-editor.js +103 -8
  48. package/dist/commands/canvas-browse.js +2 -2
  49. package/dist/commands/memory/lint.js +39 -5
  50. package/dist/commands/memory/write.js +1 -0
  51. package/dist/commands/node.js +12 -5
  52. package/dist/commands/profile/new.js +30 -5
  53. package/dist/commands/surface-tmux-spread.js +1 -3
  54. package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
  55. package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
  56. package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
  57. package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
  58. package/dist/commands/sys/config.js +18 -21
  59. package/dist/commands/sys/setup-core.d.ts +12 -1
  60. package/dist/commands/sys/setup-core.js +25 -3
  61. package/dist/commands/sys/setup.js +166 -20
  62. package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
  63. package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
  64. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
  65. package/dist/core/__tests__/fault-classifier.test.js +15 -0
  66. package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
  67. package/dist/core/__tests__/full/broker-pane-resolution.test.js +9 -6
  68. package/dist/core/__tests__/full/cascade-close.test.js +5 -2
  69. package/dist/core/__tests__/full/dead-pane-regression.test.js +6 -3
  70. package/dist/core/__tests__/full/detach-focus.test.js +10 -5
  71. package/dist/core/__tests__/full/human-new-window-regression.test.js +6 -3
  72. package/dist/core/__tests__/full/review-render-pane-regression.test.js +2 -2
  73. package/dist/core/__tests__/helpers/harness.d.ts +1 -0
  74. package/dist/core/__tests__/helpers/harness.js +28 -4
  75. package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
  76. package/dist/core/__tests__/live-mutation-verbs.test.js +5 -4
  77. package/dist/core/__tests__/review-model-floor.test.js +1 -1
  78. package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
  79. package/dist/core/__tests__/stream-watchdog.test.js +70 -0
  80. package/dist/core/__tests__/tmux-surface.test.js +72 -0
  81. package/dist/core/canvas/__tests__/remote-event-stream.test.js +11 -9
  82. package/dist/core/canvas/__tests__/render-remote.test.js +7 -4
  83. package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
  84. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +30 -13
  85. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  86. package/dist/core/canvas/browse/app.js +164 -5
  87. package/dist/core/canvas/browse/model.d.ts +10 -6
  88. package/dist/core/canvas/browse/model.js +20 -12
  89. package/dist/core/canvas/browse/pins.d.ts +4 -0
  90. package/dist/core/canvas/browse/pins.js +29 -0
  91. package/dist/core/canvas/browse/render.d.ts +13 -1
  92. package/dist/core/canvas/browse/render.js +72 -15
  93. package/dist/core/canvas/nav-model.js +5 -11
  94. package/dist/core/canvas/paths.d.ts +5 -0
  95. package/dist/core/canvas/paths.js +7 -0
  96. package/dist/core/canvas/pid.d.ts +14 -10
  97. package/dist/core/canvas/pid.js +14 -10
  98. package/dist/core/config.d.ts +53 -2
  99. package/dist/core/config.js +228 -110
  100. package/dist/core/fault-classifier.js +1 -1
  101. package/dist/core/fs-utils.d.ts +1 -0
  102. package/dist/core/fs-utils.js +15 -1
  103. package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
  104. package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
  105. package/dist/core/keybindings/catalog.d.ts +14 -0
  106. package/dist/core/keybindings/catalog.js +256 -0
  107. package/dist/core/keybindings/index.d.ts +5 -0
  108. package/dist/core/keybindings/index.js +4 -0
  109. package/dist/core/keybindings/match.d.ts +29 -0
  110. package/dist/core/keybindings/match.js +99 -0
  111. package/dist/core/keybindings/persistence.d.ts +14 -0
  112. package/dist/core/keybindings/persistence.js +31 -0
  113. package/dist/core/keybindings/resolve.d.ts +14 -0
  114. package/dist/core/keybindings/resolve.js +305 -0
  115. package/dist/core/keybindings/types.d.ts +37 -0
  116. package/dist/core/keybindings/types.js +1 -0
  117. package/dist/core/memory-resolver.d.ts +1 -1
  118. package/dist/core/memory-resolver.js +10 -3
  119. package/dist/core/predicate.d.ts +5 -3
  120. package/dist/core/predicate.js +5 -3
  121. package/dist/core/runtime/broker-protocol.d.ts +33 -5
  122. package/dist/core/runtime/broker.js +165 -14
  123. package/dist/core/runtime/launch.d.ts +39 -6
  124. package/dist/core/runtime/launch.js +79 -21
  125. package/dist/core/runtime/naming.js +3 -3
  126. package/dist/core/runtime/pi-cli.d.ts +6 -0
  127. package/dist/core/runtime/pi-cli.js +16 -2
  128. package/dist/core/runtime/placement.d.ts +2 -2
  129. package/dist/core/runtime/placement.js +4 -1
  130. package/dist/core/runtime/promote.js +4 -0
  131. package/dist/core/runtime/recap.d.ts +3 -3
  132. package/dist/core/runtime/recap.js +47 -44
  133. package/dist/core/runtime/recycle.js +6 -1
  134. package/dist/core/runtime/reset.js +5 -0
  135. package/dist/core/runtime/revive.js +28 -18
  136. package/dist/core/runtime/session-list-cache.d.ts +23 -0
  137. package/dist/core/runtime/session-list-cache.js +270 -0
  138. package/dist/core/runtime/spawn.js +51 -32
  139. package/dist/core/runtime/stream-watchdog.d.ts +26 -0
  140. package/dist/core/runtime/stream-watchdog.js +75 -0
  141. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  142. package/dist/core/runtime/tmux-chrome.js +2 -2
  143. package/dist/core/runtime/tmux.d.ts +18 -15
  144. package/dist/core/runtime/tmux.js +216 -110
  145. package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
  146. package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
  147. package/dist/core/tui/host.d.ts +30 -1
  148. package/dist/core/tui/host.js +148 -34
  149. package/dist/core/view/__tests__/transport-remote.test.js +9 -6
  150. package/dist/core/view/contract.d.ts +17 -3
  151. package/dist/daemon/crtrd-cli.d.ts +1 -1
  152. package/dist/daemon/crtrd-cli.js +1 -0
  153. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
  154. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
  155. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
  156. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
  157. package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
  158. package/dist/pi-extensions/canvas-recap.d.ts +4 -0
  159. package/dist/pi-extensions/canvas-recap.js +38 -37
  160. package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
  161. package/dist/pi-extensions/canvas-stophook.js +33 -4
  162. package/dist/suppress-experimental-warnings.d.ts +1 -0
  163. package/dist/suppress-experimental-warnings.js +15 -0
  164. package/dist/types.d.ts +3 -13
  165. package/dist/types.js +12 -36
  166. package/dist/web-client/assets/{index-CnF5r8ky.js → index--md2ylfi.js} +18 -17
  167. package/dist/web-client/assets/index-BUdm9s9s.css +2 -0
  168. package/dist/web-client/index.html +3 -3
  169. package/dist/web-client/sw.js +1 -1
  170. package/package.json +11 -8
  171. package/scripts/postinstall.mjs +1 -1
  172. package/dist/pi-extensions/canvas-nav.d.ts +0 -43
  173. package/dist/pi-extensions/canvas-nav.js +0 -640
  174. package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
  175. package/dist/pi-extensions/widget-order-bus.js +0 -34
  176. package/dist/web-client/assets/index-BnmSLNLa.css +0 -2
@@ -1,838 +1,545 @@
1
- import { before, after, test } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { spawn } from "node:child_process";
4
- import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
- import { dirname, join } from "node:path";
6
- import { tmpdir } from "node:os";
7
-
8
- let home: string;
9
- let helpers: typeof import("../../lib/subscription-state.ts");
10
- let rotationModule: typeof import("../provider-rotation.ts");
11
- let providerRotationExtension: typeof import("../provider-rotation.ts").default;
12
- const previousHome = process.env.HOME;
13
-
14
- function writeLadderConfig() {
15
- const configPath = join(home, ".crouter", "config.json");
16
- mkdirSync(dirname(configPath), { recursive: true });
17
- writeFileSync(
18
- configPath,
19
- `${JSON.stringify(
20
- {
21
- modelLadders: {
22
- anthropic: {
23
- ultra: "anthropic/claude-fable-5:high",
24
- strong: "anthropic/claude-opus-4-8:high",
25
- medium: "anthropic/claude-sonnet-4-6:high",
26
- light: "anthropic/custom-light-x",
27
- },
28
- openai: {
29
- ultra: "openai-codex/gpt-5.5:xhigh",
30
- strong: "openai-codex/gpt-5.5:high",
31
- medium: "openai-codex/gpt-5.4-mini:medium",
32
- light: "openai-codex/gpt-5.3-codex-spark",
33
- },
34
- },
35
- },
36
- null,
37
- 2,
38
- )}\n`,
39
- "utf8",
40
- );
41
- }
42
-
43
- function credential(label: string, overrides: Partial<helpers.SubscriptionCredential> = {}): helpers.SubscriptionCredential {
44
- return {
45
- label,
46
- refresh: `refresh-${label}`,
47
- access: `access-${label}`,
48
- expires: Date.now() + 60_000,
49
- rateLimitedUntil: 0,
50
- lastAttemptAt: 0,
51
- lastRateLimitedAt: 0,
52
- ...overrides,
53
- };
54
- }
55
-
56
- function resetRotationState() {
57
- rmSync(join(home, ".pi", "agent", "auth.json"), { force: true });
58
- helpers.writeSubscriptionPool("anthropic", []);
59
- helpers.writeSubscriptionPool("openai-codex", []);
60
- helpers.clearPreferredModel();
61
- }
62
-
63
- function makeAuthFileReadOnly(authPath: string): void {
64
- chmodSync(authPath, 0o444);
65
- }
66
-
67
- function makeAuthFileWritable(authPath: string): void {
68
- chmodSync(authPath, 0o600);
69
- }
70
-
71
- function observeLines(stream: NodeJS.ReadableStream, onLine: (line: string) => void): void {
72
- let buffer = "";
73
- stream.setEncoding("utf8");
74
- stream.on("data", (chunk: string) => {
75
- buffer += chunk;
76
- let newlineIndex = buffer.indexOf("\n");
77
- while (newlineIndex !== -1) {
78
- const line = buffer.slice(0, newlineIndex);
79
- buffer = buffer.slice(newlineIndex + 1);
80
- onLine(line);
81
- newlineIndex = buffer.indexOf("\n");
1
+ // Wait for clean exit first: code 0 proves the async unlock completed. A wedged child gets
2
+ // bounded SIGTERM/SIGKILL cleanup, and its test-owned lock is removed only on that fallback.
3
+ const closedWithin = (ms: number) =>
4
+ Promise.race([
5
+ childClosed,
6
+ new Promise<null>((resolve) => setTimeout(() => resolve(null), ms)),
7
+ ]);
8
+ let closed = await closedWithin(2000);
9
+ let forced = false;
10
+ if (!closed || closed.code !== 0) {
11
+ forced = true;
12
+ lockHolder.kill("SIGTERM");
13
+ closed = await closedWithin(2000);
14
+ if (!closed) {
15
+ lockHolder.kill("SIGKILL");
16
+ closed = await closedWithin(2000);
17
+ }
18
+ if (!closed) throw new Error("lock-holder child did not exit after SIGKILL");
82
19
  }
83
- });
84
- }
85
-
86
- before(async () => {
87
- home = mkdtempSync(join(tmpdir(), "pi-provider-rotation-"));
88
- process.env.HOME = home;
89
- helpers = await import("../../lib/subscription-state.ts");
90
- rotationModule = await import("../provider-rotation.ts");
91
- providerRotationExtension = rotationModule.default;
92
- });
20
+ if (forced) {
21
+ rmSync(`${poolFile}.lock`, { recursive: true, force: true });
22
+ throw new Error(`lock-holder exited without clean unlock (code=${closed.code}, signal=${closed.signal})`);
23
+ }
24
+ }
93
25
 
94
- after(() => {
95
- if (previousHome === undefined) delete process.env.HOME;
96
- else process.env.HOME = previousHome;
97
- rmSync(home, { recursive: true, force: true });
26
+ const pool = helpers.markSubscriptionAttempt("openai-codex", "work", 456);
27
+ assert.equal(pool[0].lastAttemptAt, 456);
98
28
  });
99
29
 
100
- test("normalizes provider subscription pool files", () => {
30
+ // Codex captures the login `accountId` and rejects the same account re-added under a different
31
+ // label (a sticky browser session otherwise fakes redundancy on one quota); a distinct account
32
+ // is stored with its own accountId.
33
+ test("rejects a duplicate Codex account under a new label and stores accountId for a distinct one", async () => {
101
34
  resetRotationState();
102
- helpers.writeSubscriptionPool("anthropic", [
103
- {
104
- label: " personal ",
105
- refresh: " refresh-token ",
106
- access: " access-token ",
107
- expires: 123,
108
- rateLimitedUntil: Number.NaN,
109
- lastAttemptAt: Number.NaN,
110
- lastRateLimitedAt: Number.NaN,
111
- },
112
- ]);
113
-
114
- assert.deepEqual(helpers.readSubscriptionPool("anthropic"), [
115
- {
116
- label: "personal",
117
- refresh: "refresh-token",
118
- access: "access-token",
119
- expires: 123,
120
- rateLimitedUntil: 0,
121
- lastAttemptAt: 0,
122
- lastRateLimitedAt: 0,
123
- },
124
- ]);
125
- });
35
+ const { ctx, commands, pi } = makeRotationCtx();
36
+ ctx.hasUI = true;
37
+ const notes: Array<{ message: string; type?: string }> = [];
38
+ ctx.ui.notify = (message: string, type?: string) => notes.push({ message, type });
39
+ ctx.ui.input = async () => "";
40
+ let nextAccountId = "acct-1";
41
+ rotationModule.__setLoginForProviderForTest(async () => ({ refresh: "r", access: "a", expires: Date.now() + 60_000, accountId: nextAccountId }));
126
42
 
127
- // The default label's pool entry is metadata-only -- seeding it from auth.json must never
128
- // copy a token into the pool, only bootstrap the zeroed cooldown bookkeeping that has
129
- // nowhere else to live. Assert via the literal key set (not just "the value fields are
130
- // undefined") so a regression that starts writing `refresh: undefined` etc. -- which would
131
- // still satisfy an `undefined`-only check -- is caught.
132
- test("fresh-node seeding of the default slot is metadata-only (no value keys present at all)", () => {
133
- resetRotationState();
134
- const authPath = join(home, ".pi", "agent", "auth.json");
135
- mkdirSync(dirname(authPath), { recursive: true });
136
- writeFileSync(
137
- authPath,
138
- `${JSON.stringify({
139
- "openai-codex": {
140
- type: "oauth",
141
- refresh: "refresh-codex-auth",
142
- access: "access-codex-auth",
143
- expires: 123456,
144
- },
145
- })}\n`,
146
- "utf8",
147
- );
43
+ try {
44
+ await providerRotationExtension(pi);
148
45
 
149
- const pool = helpers.readSubscriptionPool("openai-codex");
150
- assert.equal(pool.length, 1);
151
- assert.deepEqual(Object.keys(pool[0]).sort(), ["label", "lastAttemptAt", "lastRateLimitedAt", "rateLimitedUntil"]);
152
- assert.deepEqual(pool[0], {
153
- label: "openai-codex",
154
- rateLimitedUntil: 0,
155
- lastAttemptAt: 0,
156
- lastRateLimitedAt: 0,
157
- });
46
+ await commands["provider-sub"].handler("openai-codex add codex-alt", ctx);
47
+ let pool = helpers.readSubscriptionPool("openai-codex");
48
+ assert.deepEqual(pool.map((e) => e.label), ["codex-alt"]);
49
+ assert.equal(pool[0].accountId, "acct-1");
50
+
51
+ // Same account, different label -> rejected, pool unchanged.
52
+ notes.length = 0;
53
+ await commands["provider-sub"].handler("openai-codex add codex-dup", ctx);
54
+ assert.ok(notes.some((n) => n.type === "error" && /already added/i.test(n.message)), "duplicate account is rejected with an error");
55
+ pool = helpers.readSubscriptionPool("openai-codex");
56
+ assert.deepEqual(pool.map((e) => e.label), ["codex-alt"]);
57
+
58
+ // Distinct account -> stored with its own accountId.
59
+ nextAccountId = "acct-2";
60
+ await commands["provider-sub"].handler("openai-codex add codex-other", ctx);
61
+ pool = helpers.readSubscriptionPool("openai-codex");
62
+ assert.deepEqual(pool.map((e) => e.label).sort(), ["codex-alt", "codex-other"]);
63
+ assert.equal(pool.find((e) => e.label === "codex-other")?.accountId, "acct-2");
64
+ } finally {
65
+ rotationModule.__setLoginForProviderForTest(undefined);
66
+ }
158
67
  });
159
68
 
160
- // There is exactly one place the default slot's value is ever stored -- auth.json -- so
161
- // there is no second copy in the pool to ever diverge from it. Prove this directly: seed
162
- // the pool from auth.json holding credential A, then overwrite auth.json with a DIFFERENT
163
- // credential B (simulating an external refresh/re-login the pool never sees), and read the
164
- // pool again. It must still be the SAME metadata-only entry, unchanged by B -- because
165
- // there was never a value in the pool to go stale in the first place.
166
- test("no divergence is possible: overwriting auth.json elsewhere never changes the metadata-only pool entry", () => {
69
+ // Review finding 1: the DEFAULT Codex slot must carry its account identity too, so the account
70
+ // already backing the default login cannot be re-added under a second label, and list shows it.
71
+ test("the default Codex slot carries its account identity: blocks re-adding the same account and renders it in list", async () => {
167
72
  resetRotationState();
168
73
  const authPath = join(home, ".pi", "agent", "auth.json");
169
74
  mkdirSync(dirname(authPath), { recursive: true });
170
75
  writeFileSync(
171
76
  authPath,
172
- `${JSON.stringify({
173
- anthropic: { type: "oauth", refresh: "refresh-A", access: "access-A", expires: 123456 },
174
- })}\n`,
175
- "utf8",
176
- );
177
-
178
- const first = helpers.readSubscriptionPool("anthropic");
179
- assert.deepEqual(first, [{ label: "anthropic", rateLimitedUntil: 0, lastAttemptAt: 0, lastRateLimitedAt: 0 }]);
180
-
181
- writeFileSync(
182
- authPath,
183
- `${JSON.stringify({
184
- anthropic: { type: "oauth", refresh: "refresh-B", access: "access-B", expires: 999999 },
185
- })}\n`,
77
+ `${JSON.stringify({ "openai-codex": { type: "oauth", refresh: "r-def", access: "a-def", expires: Date.now() + 60_000, accountId: "acct-default" } })}\n`,
186
78
  "utf8",
187
79
  );
188
80
 
189
- const second = helpers.readSubscriptionPool("anthropic");
190
- assert.deepEqual(second, first);
191
- assert.deepEqual(Object.keys(second[0]).sort(), ["label", "lastAttemptAt", "lastRateLimitedAt", "rateLimitedUntil"]);
192
- });
193
-
194
- // The default label's pool entry is metadata-only on disk: reading it strips any stored
195
- // refresh, access, and expires fields and persists the cleaned pool immediately.
196
- test("scrubbing the default-slot value on read persists the metadata-only pool to disk", () => {
197
- resetRotationState();
198
- const poolFile = join(home, ".pi", "agent", "anthropic-subscriptions.json");
199
- mkdirSync(dirname(poolFile), { recursive: true });
200
- writeFileSync(
201
- poolFile,
202
- `${JSON.stringify([
203
- { label: "anthropic", refresh: "stale-refresh", access: "stale-access", expires: 123, rateLimitedUntil: 0, lastAttemptAt: 0, lastRateLimitedAt: 0 },
204
- ])}\n`,
205
- "utf8",
206
- );
207
-
208
- const pool = helpers.readSubscriptionPool("anthropic");
209
- assert.deepEqual(Object.keys(pool[0]).sort(), ["label", "lastAttemptAt", "lastRateLimitedAt", "rateLimitedUntil"]);
210
-
211
- // The raw FILE on disk must ALSO be scrubbed -- not just the in-memory return value.
212
- const raw = JSON.parse(readFileSync(poolFile, "utf8"));
213
- assert.deepEqual(Object.keys(raw[0]).sort(), ["label", "lastAttemptAt", "lastRateLimitedAt", "rateLimitedUntil"]);
214
- });
215
-
216
- // The brick-repro (root cause of the original bug): two concurrent turns both see the
217
- // SAME expired default-slot credential and both try to refresh it. Without a real lock
218
- // serializing read -> decide -> refresh -> write, both call the (single-use) refresh token
219
- // and the loser gets a genuine invalid_grant from the provider -- bricking that turn. Prove
220
- // the fix: the injected refresh fn is called EXACTLY ONCE across two concurrent callers;
221
- // the second caller re-reads the winner's fresh value under the lock and skips its own
222
- // network call entirely; both callers resolve without throwing; auth.json ends up holding
223
- // exactly the one refreshed value; the pool entry stays metadata-only throughout.
224
- //
225
- // This test is proven to fail without the fix, not just asserted to pass with it -- see
226
- // the fail-then-pass evidence recorded in the implementation report.
227
- test("brick-repro: concurrent refreshes of the same expired default-slot token serialize through the lock (exactly one network refresh)", async () => {
228
- resetRotationState();
229
- const authPath = join(home, ".pi", "agent", "auth.json");
230
- mkdirSync(dirname(authPath), { recursive: true });
231
- writeFileSync(
232
- authPath,
233
- `${JSON.stringify({
234
- anthropic: { type: "oauth", refresh: "shared-refresh", access: "access-stale", expires: 1 },
235
- })}\n`,
236
- "utf8",
237
- );
238
- const poolEntry = helpers.readSubscriptionPool("anthropic")[0];
239
- assert.equal(poolEntry.label, "anthropic");
240
-
241
- let refreshCalls = 0;
242
- rotationModule.__setRefreshForProviderForTest(async (_providerId, refreshToken) => {
243
- refreshCalls += 1;
244
- if (refreshCalls === 1) {
245
- return { refresh: "shared-refresh-rotated", access: "access-fresh", expires: Date.now() + 60_000 };
246
- }
247
- // Any refresh call after the first is simulating "this refresh token was already
248
- // consumed by the winner" -- a genuine invalid_grant from the provider.
249
- throw Object.assign(new Error(`HTTP request failed. status=400; body={"error":"invalid_grant"} for token ${refreshToken}`), { status: 400 });
250
- });
81
+ // Reading the pool seeds a metadata-only default entry that carries the account identity --
82
+ // no token value copied into the pool.
83
+ let pool = helpers.readSubscriptionPool("openai-codex");
84
+ assert.deepEqual(pool.map((e) => e.label), ["openai-codex"]);
85
+ assert.equal(pool[0].accountId, "acct-default");
86
+ assert.equal(pool[0].refresh, undefined);
251
87
 
88
+ const { ctx, commands, pi } = makeRotationCtx();
89
+ ctx.hasUI = true;
90
+ const notes: Array<{ message: string; type?: string }> = [];
91
+ ctx.ui.notify = (message: string, type?: string) => notes.push({ message, type });
92
+ ctx.ui.input = async () => "";
93
+ // A sticky browser re-login yields the SAME ChatGPT account already backing the default slot.
94
+ rotationModule.__setLoginForProviderForTest(async () => ({ refresh: "r2", access: "a2", expires: Date.now() + 60_000, accountId: "acct-default" }));
252
95
  try {
253
- const [a, b] = await Promise.all([
254
- rotationModule.__refreshCredentialIfNeededForTest("anthropic", poolEntry),
255
- rotationModule.__refreshCredentialIfNeededForTest("anthropic", poolEntry),
256
- ]);
257
-
258
- assert.equal(refreshCalls, 1);
259
- assert.equal(a.access, "access-fresh");
260
- assert.equal(b.access, "access-fresh");
96
+ await providerRotationExtension(pi);
261
97
 
262
- const stillMetadataOnly = helpers.readSubscriptionPool("anthropic")[0];
263
- assert.deepEqual(Object.keys(stillMetadataOnly).sort(), ["label", "lastAttemptAt", "lastRateLimitedAt", "rateLimitedUntil"]);
98
+ await commands["provider-sub"].handler("openai-codex add codex-dup", ctx);
99
+ 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");
100
+ pool = helpers.readSubscriptionPool("openai-codex");
101
+ assert.deepEqual(pool.map((e) => e.label), ["openai-codex"]);
264
102
 
265
- const authRaw = JSON.parse(readFileSync(authPath, "utf8"));
266
- assert.equal(authRaw.anthropic.access, "access-fresh");
267
- assert.equal(authRaw.anthropic.refresh, "shared-refresh-rotated");
103
+ notes.length = 0;
104
+ await commands["provider-sub"].handler("openai-codex list", ctx);
105
+ assert.match(notes.map((n) => n.message).join("\n"), /account=/);
268
106
  } finally {
269
- rotationModule.__setRefreshForProviderForTest(undefined);
107
+ rotationModule.__setLoginForProviderForTest(undefined);
270
108
  }
271
109
  });
272
110
 
273
- // A GENUINE invalid_grant (the refresh token is actually dead, not merely raced) must
274
- // still reach the real cooldown -> reauth path through the new lock-delegated seam --
275
- // the fix must not accidentally swallow or misclassify a real failure. Drives it through
276
- // the actual provider stream (not the raw helper) so `isInvalidRefreshTokenError` +
277
- // reauthentication + the AuthStorage persist all fire for real.
278
- test("a genuine invalid_grant through the default-slot lock still reauthenticates and persists to auth.json", async () => {
111
+ // Drive the REAL default-login handler path: a collision with an explicit account must reject
112
+ // before commitDefaultIdentity writes auth.json, so the failed OAuth result never partially
113
+ // switches the default credential or creates a default pool entry with the duplicate identity.
114
+ test("default-login handler rejects a colliding Codex account before switching auth.json", async () => {
279
115
  resetRotationState();
280
- writeLadderConfig();
116
+ helpers.addSubscription("openai-codex", credential("work", { accountId: "acct-X" }));
281
117
  const authPath = join(home, ".pi", "agent", "auth.json");
282
- mkdirSync(dirname(authPath), { recursive: true });
283
- writeFileSync(
284
- authPath,
285
- `${JSON.stringify({
286
- anthropic: { type: "oauth", refresh: "dead-refresh", access: "access-stale", expires: 1 },
287
- })}\n`,
288
- "utf8",
289
- );
290
- // Seed the metadata-only pool entry for the default label.
291
- helpers.readSubscriptionPool("anthropic");
292
-
293
- const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
118
+ const { ctx, commands, pi } = makeRotationCtx();
294
119
  ctx.hasUI = true;
295
- ctx.model = { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" };
296
- ctx.ui.notify = () => {};
297
- const loginCalls: string[] = [];
298
- // Plain Error, no typed .status: real pi-ai refresh failures carry the HTTP status
299
- // in the message text, so the refresh path still recognizes an invalid_grant from this
300
- // shape.
301
- rotationModule.__setRefreshForProviderForTest(async (providerId) => {
302
- if (providerId === "anthropic") {
303
- throw new Error('HTTP request failed. status=400; body={"error":"invalid_grant","error_description":"Refresh token not found or invalid"}');
304
- }
305
- throw new Error("unexpected refresh");
306
- });
307
- rotationModule.__setLoginForProviderForTest(async (providerId) => {
308
- loginCalls.push(providerId);
309
- return { refresh: "refresh-reauthed", access: "access-reauthed", expires: Date.now() + 60_000 };
310
- });
311
- rotationModule.__setStreamForProviderForTest((_model, _context, options) => {
312
- return (async function* () {
313
- yield { type: "text_delta", text: `hello with ${options?.apiKey}` };
314
- })() as any;
315
- });
316
-
120
+ const notes: Array<{ message: string; type?: string }> = [];
121
+ ctx.ui.notify = (message: string, type?: string) => notes.push({ message, type });
122
+ ctx.ui.input = async () => "";
123
+ rotationModule.__setLoginForProviderForTest(async () => ({ refresh: "r-X", access: "a-X", expires: Date.now() + 60_000, accountId: "acct-X" }));
317
124
  try {
318
125
  await providerRotationExtension(pi);
319
- await events.session_start?.({}, ctx);
320
- const stream = providers["anthropic"].streamSimple({ provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" }, ctx);
321
- const emitted = [] as any[];
322
- for await (const event of stream) emitted.push(event);
323
-
324
- assert.deepEqual(loginCalls, ["anthropic"]);
325
- assert.deepEqual(setModelCalls, []);
326
- assert.ok(emitted.some((e) => e.type === "text_delta"));
327
- assert.ok(!emitted.some((e) => e.type === "error"));
328
-
329
- // Reauth persisted to auth.json (the single owner), not the pool.
330
- const authRaw = JSON.parse(readFileSync(authPath, "utf8"));
331
- assert.equal(authRaw.anthropic.refresh, "refresh-reauthed");
332
- assert.equal(authRaw.anthropic.access, "access-reauthed");
333
-
334
- // Pool stays metadata-only after reauth too, with the cooldown cleared.
335
- const [entry] = helpers.readSubscriptionPool("anthropic");
336
- assert.deepEqual(Object.keys(entry).sort(), ["label", "lastAttemptAt", "lastRateLimitedAt", "rateLimitedUntil"]);
337
- assert.equal(entry.rateLimitedUntil, 0);
126
+ await commands["provider-sub"].handler("openai-codex add openai-codex", ctx);
127
+ assert.ok(notes.some((note) => note.type === "error" && /already added/i.test(note.message)));
128
+ assert.equal(JSON.parse(readFileSync(authPath, "utf8"))["openai-codex"], undefined, "rejected default login must not switch auth.json");
129
+ const pool = helpers.readSubscriptionPool("openai-codex");
130
+ assert.equal(pool.filter((entry) => entry.accountId === "acct-X").length, 1);
131
+ assert.equal(pool.some((entry) => entry.label === "openai-codex"), false);
338
132
  } finally {
339
- rotationModule.__setRefreshForProviderForTest(undefined);
340
133
  rotationModule.__setLoginForProviderForTest(undefined);
341
- rotationModule.__setStreamForProviderForTest(undefined);
342
134
  }
343
135
  });
344
136
 
345
- // A locked auth.json write failure must not let default-slot reauth look successful or
346
- // clear the pool's cooldown bookkeeping.
347
- test("a locked default-slot reauth write failure leaves the pool cooled down and surfaces an error", async () => {
137
+ // The handler snapshots the default slot before OAuth. A 429 recorded while OAuth is open is
138
+ // causally newer, so completing a same-account login must not reopen that quota.
139
+ test("default-login handler preserves a newer same-account cooldown recorded during OAuth", async () => {
348
140
  resetRotationState();
349
- writeLadderConfig();
350
141
  const authPath = join(home, ".pi", "agent", "auth.json");
351
142
  mkdirSync(dirname(authPath), { recursive: true });
352
143
  writeFileSync(
353
144
  authPath,
354
- `${JSON.stringify({
355
- anthropic: { type: "oauth", refresh: "dead-refresh", access: "access-stale", expires: 1 },
356
- })}\n`,
145
+ `${JSON.stringify({ "openai-codex": { type: "oauth", refresh: "r-old", access: "a-old", expires: Date.now() + 60_000, accountId: "acct-default" } })}\n`,
357
146
  "utf8",
358
147
  );
359
- // Seed the metadata-only default pool entry for the default label.
360
- helpers.readSubscriptionPool("anthropic");
148
+ helpers.readSubscriptionPool("openai-codex");
361
149
 
362
- const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
150
+ const { ctx, commands, pi } = makeRotationCtx();
363
151
  ctx.hasUI = true;
364
- ctx.model = { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" };
365
152
  ctx.ui.notify = () => {};
366
- rotationModule.__setRefreshForProviderForTest(async (providerId) => {
367
- if (providerId === "anthropic") {
368
- throw new Error('HTTP request failed. status=400; body={"error":"invalid_grant","error_description":"Refresh token not found or invalid"}');
369
- }
370
- throw new Error("unexpected refresh");
153
+ ctx.ui.input = async () => "";
154
+ let rateLimitedAt = 0;
155
+ rotationModule.__setLoginForProviderForTest(async () => {
156
+ rateLimitedAt = Date.now();
157
+ helpers.markSubscriptionRateLimited("openai-codex", "openai-codex", 60_000, rateLimitedAt, rateLimitedAt);
158
+ return { refresh: "r-new", access: "a-new", expires: Date.now() + 60_000, accountId: "acct-default" };
371
159
  });
372
- rotationModule.__setLoginForProviderForTest(async () => ({ refresh: "refresh-reauthed", access: "access-reauthed", expires: Date.now() + 60_000 }));
373
- rotationModule.__setStreamForProviderForTest((_model, _context, options) => {
374
- return (async function* () {
375
- yield { type: "text_delta", text: `hello with ${options?.apiKey}` };
376
- })() as any;
377
- });
378
-
379
160
  try {
380
- makeAuthFileReadOnly(authPath);
381
161
  await providerRotationExtension(pi);
382
- await events.session_start?.({}, ctx);
383
- const stream = providers["anthropic"].streamSimple(
384
- { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" },
385
- ctx,
386
- );
387
- const emitted = [] as any[];
388
- for await (const event of stream) emitted.push(event);
389
-
390
- assert.deepEqual(setModelCalls, []);
391
- assert.ok(emitted.some((e) => e.type === "error"));
392
- assert.ok(!emitted.some((e) => e.type === "text_delta"));
393
-
394
- const authRaw = JSON.parse(readFileSync(authPath, "utf8"));
395
- assert.equal(authRaw.anthropic.refresh, "dead-refresh");
396
-
397
- const [entry] = helpers.readSubscriptionPool("anthropic");
398
- assert.equal(entry.label, "anthropic");
399
- assert.ok(entry.rateLimitedUntil > Date.now() + 29 * 24 * 60 * 60 * 1000);
162
+ await commands["provider-sub"].handler("openai-codex add openai-codex", ctx);
163
+ const [entry] = helpers.readSubscriptionPool("openai-codex");
164
+ assert.equal(entry.accountId, "acct-default");
165
+ assert.equal(entry.lastRateLimitedAt, rateLimitedAt);
166
+ assert.equal(entry.rateLimitedUntil, rateLimitedAt + 60_000);
167
+ assert.equal(JSON.parse(readFileSync(authPath, "utf8"))["openai-codex"].access, "a-new");
400
168
  } finally {
401
- makeAuthFileWritable(authPath);
402
- rotationModule.__setRefreshForProviderForTest(undefined);
403
169
  rotationModule.__setLoginForProviderForTest(undefined);
404
- rotationModule.__setStreamForProviderForTest(undefined);
405
170
  }
406
171
  });
407
172
 
408
- // A genuine invalid_grant through the default-slot lock seam cools the credential down
409
- // and falls back cross-provider when UI is unavailable: expired OAuth in auth.json, a
410
- // metadata-only default pool entry, an authenticated Codex fallback, and the real
411
- // streamSimple path all participate.
412
- test("a genuine invalid_grant through the default-slot lock with no UI reaches cooldown and falls back cross-provider", async () => {
173
+ // auth.json is the default slot's identity source of truth: every read reconciles the existing
174
+ // metadata entry rather than only seeding it once. The pool never receives its token fields.
175
+ test("the default slot re-syncs its identity from auth.json on every pool read", () => {
413
176
  resetRotationState();
414
- writeLadderConfig();
415
177
  const authPath = join(home, ".pi", "agent", "auth.json");
416
178
  mkdirSync(dirname(authPath), { recursive: true });
417
179
  writeFileSync(
418
180
  authPath,
419
- `${JSON.stringify({
420
- anthropic: { type: "oauth", refresh: "dead-refresh", access: "access-stale", expires: 1 },
421
- })}\n`,
181
+ `${JSON.stringify({ "openai-codex": { type: "oauth", refresh: "r-old", access: "a-old", expires: Date.now() + 60_000, accountId: "acct-old" } })}\n`,
422
182
  "utf8",
423
183
  );
424
- // Seed the metadata-only default pool entry for anthropic -- the seam under test.
425
- helpers.readSubscriptionPool("anthropic");
426
- // Authenticated Codex fallback already sitting in its own pool.
427
- helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
184
+ let [entry] = helpers.readSubscriptionPool("openai-codex");
185
+ assert.equal(entry.accountId, "acct-old");
186
+ assert.equal(entry.refresh, undefined);
187
+ assert.equal(entry.access, undefined);
188
+ assert.equal(entry.expires, undefined);
428
189
 
429
- const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
430
- ctx.model = { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" };
431
- // ctx.hasUI is deliberately left falsy: reauth must be impossible, so the only path
432
- // forward is cooldown + cross-provider fallback.
433
- const streamedModels: any[] = [];
434
- rotationModule.__setRefreshForProviderForTest(async (providerId) => {
435
- if (providerId === "anthropic") {
436
- // Plain Error, no typed .status: the refresh path reads the invalid_grant shape from
437
- // the message text, matching production failures.
438
- throw new Error('HTTP request failed. status=400; body={"error":"invalid_grant","error_description":"Refresh token not found or invalid"}');
439
- }
440
- throw new Error("unexpected refresh");
441
- });
442
- rotationModule.__setStreamForProviderForTest((model) => {
443
- streamedModels.push(model);
444
- return (async function* () {
445
- yield { type: "text_delta", text: "hello from codex fallback" };
446
- })() as any;
447
- });
448
-
449
- try {
450
- await providerRotationExtension(pi);
451
- await events.session_start?.({}, ctx);
452
- const stream = providers["anthropic"].streamSimple(
453
- { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" },
454
- ctx,
455
- );
456
- const emitted = [] as any[];
457
- for await (const event of stream) emitted.push(event);
458
-
459
- // Fell back cross-provider and actually served the turn on Codex.
460
- assert.deepEqual(setModelCalls.map((m) => m.provider), ["openai-codex"]);
461
- assert.equal(streamedModels.length, 1);
462
- assert.equal(streamedModels[0].provider, "openai-codex");
463
- assert.ok(emitted.some((e) => e.type === "text_delta"));
464
- assert.ok(!emitted.some((e) => e.type === "error"));
465
-
466
- // The default slot's metadata entry got the ~30-day invalid-refresh-token cooldown,
467
- // through the real `withLockAsync` seam -- not a bypass.
468
- const [entry] = helpers.readSubscriptionPool("anthropic");
469
- assert.equal(entry.label, "anthropic");
470
- assert.ok(entry.rateLimitedUntil > Date.now() + 29 * 24 * 60 * 60 * 1000);
471
- assert.deepEqual(Object.keys(entry).sort(), ["label", "lastAttemptAt", "lastRateLimitedAt", "rateLimitedUntil"]);
472
-
473
- // No UI -- no reauth happened; auth.json still holds the dead credential unchanged.
474
- const authRaw = JSON.parse(readFileSync(authPath, "utf8"));
475
- assert.equal(authRaw.anthropic.refresh, "dead-refresh");
476
- } finally {
477
- rotationModule.__setRefreshForProviderForTest(undefined);
478
- rotationModule.__setStreamForProviderForTest(undefined);
479
- }
480
- });
481
-
482
- test("/provider-sub add still adds an explicit additional subscription alongside the default slot", () => {
483
- resetRotationState();
484
- helpers.writeSubscriptionPool("anthropic", [credential("anthropic", { refresh: "refresh-first", access: "access-first" })]);
485
-
486
- helpers.upsertSubscription("anthropic", credential("work", { refresh: "refresh-work", access: "access-work" }));
487
-
488
- assert.deepEqual(helpers.readSubscriptionPool("anthropic").map((entry) => entry.label), ["anthropic", "work"]);
489
- });
490
-
491
- // /provider-sub <provider> add accepts the default label as the login-backed slot.
492
- // The pool stays metadata-only for that label, and the registered command handler writes
493
- // the credential to auth.json.
494
-
495
- test("/provider-sub add with the default label persists the login credential to auth.json instead of discarding it", async () => {
496
- resetRotationState();
497
- const authPath = join(home, ".pi", "agent", "auth.json");
498
-
499
- const { ctx, commands, pi } = makeRotationCtx();
500
- ctx.hasUI = true;
501
- ctx.ui.notify = () => {};
502
- ctx.ui.input = async (_message: string, placeholder: string) => placeholder; // accept the prompt's own default
503
- rotationModule.__setLoginForProviderForTest(async () => ({ refresh: "refresh-default-login", access: "access-default-login", expires: Date.now() + 60_000 }));
504
-
505
- try {
506
- await providerRotationExtension(pi);
507
- await commands["provider-sub"].handler("anthropic add", ctx);
508
-
509
- // The credential must be persisted to auth.json (the single owner), NOT discarded.
510
- const authRaw = JSON.parse(readFileSync(authPath, "utf8"));
511
- assert.equal(authRaw.anthropic.refresh, "refresh-default-login");
512
- assert.equal(authRaw.anthropic.access, "access-default-login");
513
-
514
- // The pool gets a metadata-only default entry (no value written into it) with the
515
- // cooldown cleared -- not a broken "authenticated but valueless" slot.
516
- const [entry] = helpers.readSubscriptionPool("anthropic");
517
- assert.equal(entry.label, "anthropic");
518
- assert.deepEqual(Object.keys(entry).sort(), ["label", "lastAttemptAt", "lastRateLimitedAt", "rateLimitedUntil"]);
519
- assert.equal(entry.rateLimitedUntil, 0);
520
- } finally {
521
- rotationModule.__setLoginForProviderForTest(undefined);
522
- }
190
+ writeFileSync(
191
+ authPath,
192
+ `${JSON.stringify({ "openai-codex": { type: "oauth", refresh: "r-new", access: "a-new", expires: Date.now() + 60_000, accountId: "acct-new" } })}\n`,
193
+ "utf8",
194
+ );
195
+ [entry] = helpers.readSubscriptionPool("openai-codex");
196
+ assert.equal(entry.accountId, "acct-new");
197
+ assert.equal(entry.refresh, undefined);
198
+ assert.equal(entry.access, undefined);
199
+ assert.equal(entry.expires, undefined);
523
200
  });
524
201
 
525
- // A locked auth.json write failure must not let the default-label add path clear the
526
- // pool cooldown bookkeeping or pretend the add succeeded.
527
- test("/provider-sub add with the default label keeps the pool cooldown when auth.json write fails", async () => {
202
+ // An auth writer outside provider rotation can change the default identity without the pool
203
+ // lock. Reconciliation validates under that lock and refuses to return or persist two slots for
204
+ // one account; removing the explicit duplicate remains a valid repair operation.
205
+ test("auth-driven default reconciliation refuses a colliding explicit identity", async () => {
528
206
  resetRotationState();
529
207
  const authPath = join(home, ".pi", "agent", "auth.json");
208
+ const poolPath = join(home, ".pi", "agent", "openai-codex-subscriptions.json");
530
209
  mkdirSync(dirname(authPath), { recursive: true });
531
210
  writeFileSync(
532
211
  authPath,
533
- `${JSON.stringify({
534
- anthropic: { type: "oauth", refresh: "refresh-existing", access: "access-existing", expires: Date.now() + 60_000 },
535
- })}\n`,
212
+ `${JSON.stringify({ "openai-codex": { type: "oauth", refresh: "r-old", access: "a-old", expires: 100, accountId: "acct-old" } })}\n`,
536
213
  "utf8",
537
214
  );
538
- helpers.readSubscriptionPool("anthropic");
539
- helpers.markSubscriptionRateLimited("anthropic", "anthropic", 60_000, 100, 200);
540
- const beforeCooldown = helpers.readSubscriptionPool("anthropic")[0].rateLimitedUntil;
215
+ helpers.readSubscriptionPool("openai-codex");
216
+ helpers.addSubscription("openai-codex", credential("work", { accountId: "acct-new" }));
217
+
218
+ writeFileSync(
219
+ authPath,
220
+ `${JSON.stringify({ "openai-codex": { type: "oauth", refresh: "r-new", access: "a-new", expires: 200, accountId: "acct-new" } })}\n`,
221
+ "utf8",
222
+ );
223
+ assert.throws(
224
+ () => helpers.readSubscriptionPool("openai-codex"),
225
+ (err: unknown) => err instanceof helpers.DuplicateSubscriptionError && /already added/i.test((err as Error).message),
226
+ );
227
+ const rawAfterRejection = JSON.parse(readFileSync(poolPath, "utf8"));
228
+ assert.equal(rawAfterRejection.find((entry: { label: string }) => entry.label === "openai-codex").accountId, "acct-old");
541
229
 
542
230
  const { ctx, commands, pi } = makeRotationCtx();
543
- ctx.hasUI = true;
544
231
  ctx.ui.notify = () => {};
545
- ctx.ui.input = async (_message: string, placeholder: string) => placeholder;
546
- rotationModule.__setLoginForProviderForTest(async () => ({ refresh: "refresh-default-login", access: "access-default-login", expires: Date.now() + 60_000 }));
547
-
548
- try {
549
- makeAuthFileReadOnly(authPath);
550
- await providerRotationExtension(pi);
551
- await commands["provider-sub"].handler("anthropic add", ctx);
552
-
553
- const authRaw = JSON.parse(readFileSync(authPath, "utf8"));
554
- assert.equal(authRaw.anthropic.refresh, "refresh-existing");
555
- const [entry] = helpers.readSubscriptionPool("anthropic");
556
- assert.equal(entry.label, "anthropic");
557
- assert.equal(entry.rateLimitedUntil, beforeCooldown);
558
- } finally {
559
- makeAuthFileWritable(authPath);
560
- rotationModule.__setLoginForProviderForTest(undefined);
561
- }
232
+ await providerRotationExtension(pi);
233
+ await commands["provider-sub"].handler("openai-codex rm work", ctx);
234
+ assert.deepEqual(
235
+ helpers.readSubscriptionPool("openai-codex").map((entry) => ({ label: entry.label, accountId: entry.accountId })),
236
+ [{ label: "openai-codex", accountId: "acct-new" }],
237
+ );
562
238
  });
563
239
 
564
- // The default metadata slot is seeded whenever auth.json holds an OAuth credential for
565
- // the provider, even when the pool already contains explicit non-default accounts.
566
- test("seeds the default metadata slot even when the pool already has an explicit non-default account", () => {
240
+ // Explicit reauth uses the identity-checked upsert: it can refresh its OWN stored identity, but
241
+ // cannot reauth into an account held by another slot.
242
+ test("the uniqueness-aware reauth upsert refreshes its slot but rejects another slot's account", () => {
567
243
  resetRotationState();
568
- helpers.writeSubscriptionPool("anthropic", [credential("work", { refresh: "refresh-work", access: "access-work" })]);
569
-
570
- const authPath = join(home, ".pi", "agent", "auth.json");
571
- mkdirSync(dirname(authPath), { recursive: true });
572
- writeFileSync(
573
- authPath,
574
- `${JSON.stringify({
575
- anthropic: { type: "oauth", refresh: "refresh-native-login", access: "access-native-login", expires: Date.now() + 60_000 },
576
- })}\n`,
577
- "utf8",
244
+ helpers.writeSubscriptionPool("openai-codex", [credential("work", { accountId: "acct-old" }), credential("other", { accountId: "acct-held" })]);
245
+ helpers.upsertSubscriptionWithUniqueAccount("openai-codex", credential("work", { accountId: "acct-new", refresh: "r-new", access: "a-new" }));
246
+ let pool = helpers.readSubscriptionPool("openai-codex");
247
+ assert.equal(pool.find((entry) => entry.label === "work")?.accountId, "acct-new");
248
+ assert.throws(
249
+ () => helpers.upsertSubscriptionWithUniqueAccount("openai-codex", credential("work", { accountId: "acct-held", refresh: "r-collision", access: "a-collision" })),
250
+ (err: unknown) => err instanceof helpers.DuplicateSubscriptionError && /already added/i.test((err as Error).message),
578
251
  );
579
-
580
- const pool = helpers.readSubscriptionPool("anthropic");
581
- assert.deepEqual(pool.map((entry) => entry.label).sort(), ["anthropic", "work"]);
582
- const defaultEntry = pool.find((entry) => entry.label === "anthropic");
583
- assert.deepEqual(Object.keys(defaultEntry!).sort(), ["label", "lastAttemptAt", "lastRateLimitedAt", "rateLimitedUntil"]);
252
+ pool = helpers.readSubscriptionPool("openai-codex");
253
+ assert.equal(pool.find((entry) => entry.label === "work")?.accountId, "acct-new");
254
+ assert.equal(pool.filter((entry) => entry.accountId === "acct-held").length, 1);
584
255
  });
585
256
 
586
- // An api_key auth.json record does not seed the default metadata slot.
587
- test("does not seed a default metadata slot for an api_key auth.json record", () => {
257
+ // A rejected default identity move must leave BOTH stores untouched: account uniqueness happens
258
+ // inside the pool lock before commitDefaultIdentity writes auth.json under the nested auth lock.
259
+ test("commitDefaultIdentity rejects an explicit account without partially switching auth.json or the pool", () => {
588
260
  resetRotationState();
589
261
  const authPath = join(home, ".pi", "agent", "auth.json");
590
262
  mkdirSync(dirname(authPath), { recursive: true });
591
263
  writeFileSync(
592
264
  authPath,
593
- `${JSON.stringify({
594
- anthropic: { type: "api_key", key: "sk-not-oauth" },
595
- })}\n`,
265
+ `${JSON.stringify({ "openai-codex": { type: "oauth", refresh: "r-old", access: "a-old", expires: 100, accountId: "acct-old" } })}\n`,
596
266
  "utf8",
597
267
  );
598
-
599
- assert.deepEqual(helpers.readSubscriptionPool("anthropic"), []);
268
+ helpers.writeSubscriptionPool("openai-codex", [
269
+ { label: "openai-codex", accountId: "acct-old", rateLimitedUntil: 0, lastAttemptAt: 10, lastRateLimitedAt: 0 },
270
+ credential("work", { accountId: "acct-new" }),
271
+ ]);
272
+ const authBefore = readFileSync(authPath, "utf8");
273
+ const poolBefore = helpers.readSubscriptionPool("openai-codex");
274
+ assert.throws(
275
+ () =>
276
+ helpers.commitDefaultIdentity("openai-codex", {
277
+ refresh: "r-new",
278
+ access: "a-new",
279
+ expires: 200,
280
+ accountId: "acct-new",
281
+ lastRateLimitedAt: 0,
282
+ }),
283
+ (err: unknown) => err instanceof helpers.DuplicateSubscriptionError && /already added/i.test((err as Error).message),
284
+ );
285
+ assert.equal(readFileSync(authPath, "utf8"), authBefore);
286
+ assert.deepEqual(helpers.readSubscriptionPool("openai-codex"), poolBefore);
600
287
  });
601
288
 
602
- // Hold the real auth.json lock in a separate process and prove the seed read fails
603
- // visibly instead of silently returning an empty default slot while the file is locked.
604
- test("locked auth.json seeding fails visibly instead of treating an existing default as absent", async () => {
289
+ // A same-account login uses its pre-OAuth rate-limit timestamp as a causal basis. A newer 429
290
+ // survives, an equal-basis login may clear it, and a genuine account switch starts new quota clean.
291
+ test("commitDefaultIdentity preserves newer same-account cooldowns and resets old-account cooldowns", () => {
605
292
  resetRotationState();
606
293
  const authPath = join(home, ".pi", "agent", "auth.json");
607
294
  mkdirSync(dirname(authPath), { recursive: true });
608
295
  writeFileSync(
609
296
  authPath,
610
- `${JSON.stringify({
611
- anthropic: { type: "oauth", refresh: "refresh-locked", access: "access-locked", expires: Date.now() + 60_000 },
612
- })}
613
- `,
297
+ `${JSON.stringify({ "openai-codex": { type: "oauth", refresh: "r-old", access: "a-old", expires: 100, accountId: "acct-old" } })}\n`,
614
298
  "utf8",
615
299
  );
616
-
617
- const subscriptionStateUrl = new URL("../../lib/subscription-state.ts", import.meta.url).href;
618
- const readProbe = spawn(
619
- process.execPath,
620
- [
621
- "--import",
622
- "tsx/esm",
623
- "--input-type=module",
624
- "-e",
625
- String.raw`const { readSubscriptionPool } = await import(${JSON.stringify(subscriptionStateUrl)});
626
- process.stdout.write("ready\n");
627
- await new Promise((resolve) => {
628
- process.stdin.setEncoding("utf8");
629
- process.stdin.once("data", resolve);
630
- });
631
- process.stdout.write("about-to-read\n");
632
- const pool = readSubscriptionPool("anthropic");
633
- process.stdout.write("result:" + JSON.stringify(pool) + "\n");`,
634
- ],
635
- { env: { ...process.env, HOME: home }, stdio: ["pipe", "pipe", "pipe"] },
636
- );
637
- let sawReady = false;
638
- let sawResult = false;
639
- let readStderr = "";
640
- readProbe.stderr!.setEncoding("utf8");
641
- readProbe.stderr!.on("data", (chunk: string) => {
642
- readStderr += chunk;
643
- });
644
- const readReady = new Promise<void>((resolve, reject) => {
645
- readProbe.once("error", reject);
646
- readProbe.once("close", (code) => {
647
- if (!sawReady) reject(new Error(`read-probe exited with ${code} before signalling readiness: ${readStderr.trim()}`));
648
- });
649
- observeLines(readProbe.stdout!, (line) => {
650
- if (line === "ready") {
651
- sawReady = true;
652
- resolve();
653
- return;
654
- }
655
- if (line.startsWith("result:")) sawResult = true;
656
- });
657
- });
658
- const readDone = new Promise<number | null>((resolve, reject) => {
659
- readProbe.once("error", reject);
660
- readProbe.once("close", resolve);
300
+ helpers.readSubscriptionPool("openai-codex");
301
+ helpers.markSubscriptionRateLimited("openai-codex", "openai-codex", 60_000, 100, 100);
302
+
303
+ helpers.commitDefaultIdentity("openai-codex", {
304
+ refresh: "r-stale",
305
+ access: "a-stale",
306
+ expires: 200,
307
+ accountId: "acct-old",
308
+ lastRateLimitedAt: 0,
661
309
  });
662
- await readReady;
663
-
664
- const lockHolder = spawn(
665
- process.execPath,
666
- [
667
- "--import",
668
- "tsx/esm",
669
- "--input-type=module",
670
- "-e",
671
- String.raw`import { FileAuthStorageBackend } from "@earendil-works/pi-coding-agent";
672
- const backend = new FileAuthStorageBackend();
673
- await backend.withLockAsync(async () => {
674
- process.stdout.write("locked\n");
675
- await new Promise((resolve) => {
676
- process.stdin.setEncoding("utf8");
677
- process.stdin.once("data", resolve);
310
+ let [entry] = helpers.readSubscriptionPool("openai-codex");
311
+ assert.equal(entry.lastRateLimitedAt, 100);
312
+ assert.equal(entry.rateLimitedUntil, 60_100, "a newer same-account 429 survives the stale login");
313
+
314
+ helpers.commitDefaultIdentity("openai-codex", {
315
+ refresh: "r-current",
316
+ access: "a-current",
317
+ expires: 300,
318
+ accountId: "acct-old",
319
+ lastRateLimitedAt: 100,
678
320
  });
679
- return { result: undefined };
680
- });`,
681
- ],
682
- { env: { ...process.env, HOME: home }, stdio: ["pipe", "pipe", "pipe"] },
683
- );
684
- let lockStderr = "";
685
- lockHolder.stderr!.setEncoding("utf8");
686
- lockHolder.stderr!.on("data", (chunk: string) => {
687
- lockStderr += chunk;
321
+ [entry] = helpers.readSubscriptionPool("openai-codex");
322
+ assert.equal(entry.rateLimitedUntil, 0, "an equal-basis same-account login may clear the cooldown");
323
+
324
+ helpers.markSubscriptionRateLimited("openai-codex", "openai-codex", 60_000, 200, 200);
325
+ helpers.commitDefaultIdentity("openai-codex", {
326
+ refresh: "r-other",
327
+ access: "a-other",
328
+ expires: 400,
329
+ accountId: "acct-other",
330
+ lastRateLimitedAt: 100,
688
331
  });
689
- let sawLocked = false;
690
- const lockReady = new Promise<void>((resolve, reject) => {
691
- lockHolder.once("error", reject);
692
- lockHolder.once("close", (code) => {
693
- if (!sawLocked) reject(new Error(`lock-holder exited with ${code} before signalling readiness: ${lockStderr.trim()}`));
694
- });
695
- observeLines(lockHolder.stdout!, (line) => {
696
- if (line === "locked") {
697
- sawLocked = true;
698
- resolve();
699
- }
700
- });
701
- });
702
- const lockDone = new Promise<void>((resolve, reject) => {
703
- lockHolder.once("error", reject);
704
- lockHolder.once("close", (code) => {
705
- if (code === 0) resolve();
706
- else reject(new Error(`lock-holder exited with ${code}: ${lockStderr.trim()}`));
707
- });
708
- });
709
- await lockReady;
710
- readProbe.stdin!.write("go\n");
711
- readProbe.stdin!.end();
712
- const readExitCode = await readDone;
713
- lockHolder.stdin!.write("release\n");
714
- lockHolder.stdin!.end();
715
-
716
- assert.equal(sawResult, false);
717
- assert.notEqual(readExitCode, 0);
718
- assert.match(readStderr, /ELOCKED|Lock file is already being held/);
719
- await lockDone;
720
- assert.deepEqual(helpers.readSubscriptionPool("anthropic"), [{ label: "anthropic", rateLimitedUntil: 0, lastAttemptAt: 0, lastRateLimitedAt: 0 }]);
332
+ [entry] = helpers.readSubscriptionPool("openai-codex");
333
+ assert.equal(entry.accountId, "acct-other");
334
+ assert.equal(entry.lastRateLimitedAt, 200);
335
+ assert.equal(entry.rateLimitedUntil, 0, "a different account does not inherit the old account's quota cooldown");
721
336
  });
722
337
 
723
- test("resolves Anthropic to strength-matched OpenAI Codex fallback", () => {
338
+ // Label AND account uniqueness are enforced INSIDE the locked add transform
339
+ // (throwing a typed error), so the check-then-append is atomic rather than a lock-free race.
340
+ test("addSubscription normalizes labels and enforces Codex account uniqueness inside the locked transform", () => {
724
341
  resetRotationState();
725
- writeLadderConfig();
726
-
727
- assert.deepEqual(helpers.resolveFallbackTarget("anthropic", "anthropic/claude-opus-4-8:high"), {
728
- providerId: "openai-codex",
729
- modelId: "gpt-5.5",
730
- label: "openai-codex/gpt-5.5:high",
731
- strength: "strong",
732
- thinkingLevel: "high",
733
- });
734
- });
735
-
736
- test("resolves OpenAI Codex to strength-matched Anthropic fallback", () => {
737
- writeLadderConfig();
738
-
739
- assert.deepEqual(helpers.resolveFallbackTarget("openai-codex", "openai-codex/gpt-5.4-mini:medium"), {
740
- providerId: "anthropic",
741
- modelId: "claude-sonnet-4-6",
742
- label: "anthropic/claude-sonnet-4-6:high",
743
- strength: "medium",
744
- thinkingLevel: "high",
745
- });
342
+ helpers.addSubscription("openai-codex", credential("work", { accountId: "acct-1" }));
343
+ assert.throws(
344
+ () => helpers.addSubscription("openai-codex", credential(" work ", { accountId: "acct-2" })),
345
+ (err: unknown) => err instanceof helpers.DuplicateSubscriptionError && /already exists/i.test((err as Error).message),
346
+ );
347
+ assert.throws(
348
+ () => helpers.addSubscription("openai-codex", credential(" ", { accountId: "acct-3" })),
349
+ (err: unknown) => !(err instanceof helpers.DuplicateSubscriptionError) && /cannot be empty/i.test((err as Error).message),
350
+ );
351
+ assert.throws(
352
+ () => helpers.addSubscription("openai-codex", credential("work-2", { accountId: "acct-1" })),
353
+ (err: unknown) => err instanceof helpers.DuplicateSubscriptionError && /already added/i.test((err as Error).message),
354
+ );
355
+ helpers.addSubscription("openai-codex", credential("work-3", { accountId: "acct-3" }));
356
+ assert.deepEqual(helpers.readSubscriptionPool("openai-codex").map((entry) => entry.label).sort(), ["work", "work-3"]);
746
357
  });
747
358
 
748
- // Ultra and strong share the bare model id gpt-5.5; the live thinking level selects
749
- // the matching ladder rung.
750
- test("disambiguates colliding ladder rungs by the live thinking level", () => {
359
+ // The first contender holds the real pool lock inside addSubscription's transform. The second
360
+ // contender cannot reach its own transform until it has first attempted that same real lock.
361
+ // This makes an outside-lock uniqueness check deterministic: both checks run while the pool is
362
+ // empty, then both appends succeed after the holder releases.
363
+ test("cross-process explicit adds admit exactly one duplicate Codex account", async () => {
751
364
  resetRotationState();
752
- writeLadderConfig();
753
- const config = helpers.readRotationConfig();
754
-
755
- // xhigh -> ultra rung -> Anthropic ultra (claude-fable-5)
756
- assert.deepEqual(helpers.resolveFallbackTarget("openai-codex", "gpt-5.5", config, "xhigh"), {
757
- providerId: "anthropic",
758
- modelId: "claude-fable-5",
759
- label: "anthropic/claude-fable-5:high",
760
- strength: "ultra",
761
- thinkingLevel: "high",
365
+ const poolPath = join(home, ".pi", "agent", "openai-codex-subscriptions.json");
366
+ const releasePath = join(home, "release-cross-process-add-race");
367
+ const subscriptionStateUrl = new URL("../../lib/subscription-state.ts", import.meta.url).href;
368
+ const spawnContender = (label: string, role: "holder" | "waiter") => {
369
+ const child = spawn(
370
+ process.execPath,
371
+ [
372
+ "--import",
373
+ "tsx/esm",
374
+ "--input-type=module",
375
+ "-e",
376
+ String.raw`import { existsSync, writeSync } from "node:fs";
377
+ import { FileAuthStorageBackend } from "@earendil-works/pi-coding-agent";
378
+ const { addSubscription, DuplicateSubscriptionError } = await import(${JSON.stringify(subscriptionStateUrl)});
379
+ const poolPath = ${JSON.stringify(poolPath)};
380
+ const releasePath = ${JSON.stringify(releasePath)};
381
+ const role = ${JSON.stringify(role)};
382
+ const originalWithLock = FileAuthStorageBackend.prototype.withLock;
383
+ FileAuthStorageBackend.prototype.withLock = function (fn) {
384
+ if (this.authPath !== poolPath) return originalWithLock.call(this, fn);
385
+ writeSync(1, "attempt\n");
386
+ if (role === "waiter") {
387
+ // This is a real acquisition attempt against the holder's real lock. It happens after any
388
+ // hypothetical outside-lock validation but before the transform can run.
389
+ try {
390
+ originalWithLock.call(new FileAuthStorageBackend(poolPath), () => ({ result: undefined }));
391
+ throw new Error("waiter acquired the pool lock while the holder was still inside it");
392
+ } catch (error) {
393
+ if ((error).code !== "ELOCKED") throw error;
394
+ }
395
+ writeSync(1, "acquiring\n");
396
+ while (!existsSync(releasePath)) {}
397
+ }
398
+ return originalWithLock.call(this, (current) => {
399
+ if (role === "holder") {
400
+ writeSync(1, "inside\n");
401
+ while (!existsSync(releasePath)) {}
402
+ }
403
+ return fn(current);
762
404
  });
763
-
764
- // high -> strong rung -> Anthropic strong (claude-opus-4-8)
765
- assert.equal(helpers.resolveFallbackTarget("openai-codex", "gpt-5.5", config, "high").modelId, "claude-opus-4-8");
766
-
767
- // No thinking level falls back to the default-strength tie-break (strong -> opus).
768
- assert.equal(helpers.resolveFallbackTarget("openai-codex", "gpt-5.5", config).modelId, "claude-opus-4-8");
769
- });
770
-
771
- test("parses Retry-After seconds and HTTP dates", () => {
772
- const now = Date.UTC(2026, 5, 16, 18, 30, 0);
773
- assert.equal(helpers.parseRetryAfterHeader("1.5", now), 1500);
774
-
775
- const retryAt = new Date(now + 15_000).toUTCString();
776
- const parsed = helpers.parseRetryAfterHeader(retryAt, now);
777
- assert.ok(parsed !== undefined && parsed >= 14_000 && parsed <= 15_000);
778
- assert.equal(helpers.parseRetryAfterHeader("", now), undefined);
779
- assert.equal(helpers.parseRetryAfterHeader("not-a-delay", now), undefined);
780
- });
781
-
782
- test("restores preferred model when its provider pool frees up", async () => {
783
- resetRotationState();
784
- writeLadderConfig();
785
- helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
786
- helpers.writeRotationConfig({ preferredModel: { providerId: "openai-codex", modelId: "gpt-5.5" } });
787
-
788
- const setModelCalls: any[] = [];
789
- const ctx = {
790
- model: { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" },
791
- modelRegistry: {
792
- find(provider: string, id: string) {
793
- return { provider, id, api: provider === "openai-codex" ? "openai-codex-responses" : "anthropic-messages" };
794
- },
795
- },
796
- } as any;
797
- const pi = {
798
- async setModel(model: any) {
799
- setModelCalls.push(model);
800
- ctx.model = model;
801
- return true;
802
- },
803
- } as any;
804
-
805
- const restored = await helpers.restorePreferredModelIfPossible(pi, ctx);
806
-
807
- assert.equal(restored, true);
808
- assert.deepEqual(setModelCalls[0], {
809
- provider: "openai-codex",
810
- id: "gpt-5.5",
811
- api: "openai-codex-responses",
405
+ };
406
+ try {
407
+ addSubscription("openai-codex", {
408
+ label: ${JSON.stringify(label)}, refresh: "refresh-${label}", access: "access-${label}", expires: Date.now() + 60_000,
409
+ accountId: "acct-cross-process-race", rateLimitedUntil: 0, lastAttemptAt: 0, lastRateLimitedAt: 0,
812
410
  });
813
- assert.equal(helpers.readRotationConfig().preferredModel, undefined);
411
+ writeSync(1, "outcome:success\n");
412
+ } catch (error) {
413
+ const outcome = error instanceof DuplicateSubscriptionError ? "duplicate" : "error";
414
+ writeSync(1, "outcome:" + outcome + "\n");
415
+ if (outcome === "error") process.stderr.write(String(error));
416
+ }`,
417
+ ],
418
+ { env: { ...process.env, HOME: home }, stdio: ["pipe", "pipe", "pipe"] },
419
+ );
420
+ const signals = new Set<string>();
421
+ let outcome: string | undefined;
422
+ let stderr = "";
423
+ let stdout = "";
424
+ child.stdout!.on("data", (chunk: string) => {
425
+ stdout += chunk;
426
+ });
427
+ child.stderr!.setEncoding("utf8");
428
+ child.stderr!.on("data", (chunk: string) => {
429
+ stderr += chunk;
430
+ });
431
+ const signal = (expected: string) =>
432
+ new Promise<void>((resolve, reject) => {
433
+ child.once("error", reject);
434
+ child.once("close", (code) => {
435
+ if (!signals.has(expected)) reject(new Error(`${label} exited with ${code} before ${expected}: ${stderr.trim()}`));
436
+ });
437
+ observeLines(child.stdout!, (line) => {
438
+ signals.add(line);
439
+ if (line === expected) resolve();
440
+ if (line.startsWith("outcome:")) outcome = line.slice("outcome:".length);
441
+ });
442
+ });
443
+ const closed = new Promise<number | null>((resolve) => child.once("close", resolve));
444
+ const stdoutEnded = new Promise<void>((resolve) => child.stdout!.once("end", resolve));
445
+ return { child, closed, label, outcome: () => outcome, signal, stderr: () => stderr, stdout: () => stdout, stdoutEnded };
446
+ };
447
+
448
+ const holder = spawnContender("race-left", "holder");
449
+ const contenders = [holder];
450
+ try {
451
+ await within(Promise.all([holder.signal("attempt"), holder.signal("inside")]), 10_000, "holder did not reach the locked transform within 10s");
452
+ const waiter = spawnContender("race-right", "waiter");
453
+ contenders.push(waiter);
454
+ await within(Promise.all([waiter.signal("attempt"), waiter.signal("acquiring")]), 10_000, "waiter did not attempt the held pool lock within 10s");
455
+ writeFileSync(releasePath, "release\n", "utf8");
456
+ const exitCodes = await within(Promise.all(contenders.map((contender) => contender.closed)), 3000, "cross-process contenders did not exit within 3s");
457
+ await within(Promise.all(contenders.map((contender) => contender.stdoutEnded)), 1000, "cross-process contender output did not close within 1s");
458
+ assert.deepEqual(exitCodes, [0, 0], contenders.map((contender) => contender.stderr()).join("\n"));
459
+ assert.deepEqual(contenders.map((contender) => contender.outcome()).sort(), ["duplicate", "success"], contenders.map((contender) => contender.stdout()).join("\n"));
460
+ const pool = helpers.readSubscriptionPool("openai-codex");
461
+ assert.equal(pool.length, 1);
462
+ assert.ok(["race-left", "race-right"].includes(pool[0].label));
463
+ } finally {
464
+ const errors: unknown[] = [];
465
+ try {
466
+ writeFileSync(releasePath, "release\n", "utf8");
467
+ } catch (error) {
468
+ errors.push(error);
469
+ }
470
+ const teardown = async (contender: ReturnType<typeof spawnContender>) => {
471
+ if (contender.child.exitCode !== null || contender.child.signalCode !== null) return;
472
+ try {
473
+ contender.child.stdin!.end();
474
+ } catch {
475
+ // The child may already have closed its input.
476
+ }
477
+ if (await within(contender.closed.then(() => true), 1000, `${contender.label} did not exit gracefully within 1s`).catch(() => false)) return;
478
+ contender.child.kill("SIGTERM");
479
+ if (await within(contender.closed.then(() => true), 1000, `${contender.label} did not exit after SIGTERM`).catch(() => false)) return;
480
+ contender.child.kill("SIGKILL");
481
+ await within(contender.closed, 1000, `${contender.label} did not exit after SIGKILL`);
482
+ };
483
+ const results = await Promise.allSettled(contenders.map(teardown));
484
+ errors.push(...results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])));
485
+ try {
486
+ assert.equal(existsSync(`${poolPath}.lock`), false, "cross-process add race must release the pool lock directory");
487
+ } catch (error) {
488
+ errors.push(error);
489
+ }
490
+ if (errors.length) throw new AggregateError(errors, "cross-process contender teardown failed");
491
+ }
814
492
  });
815
493
 
816
- test("keeps newest Retry-After cooldown across overlapping attempts", () => {
494
+ // Corruption can arrive after commitDefaultIdentity projects auth.json for its pool-lock
495
+ // callback but before it obtains the nested auth lock to persist. That persistence parse must
496
+ // fail loudly and leave both stores precisely as they were at the corruption point.
497
+ test("malformed auth during nested default persistence preserves malformed bytes and the pool cooldown", () => {
817
498
  resetRotationState();
818
- helpers.writeSubscriptionPool("anthropic", [credential("personal")]);
819
-
820
- helpers.markSubscriptionAttempt("anthropic", "personal", 100);
821
- helpers.markSubscriptionAttempt("anthropic", "personal", 200);
822
- helpers.markSubscriptionRateLimited("anthropic", "personal", 60_000, 100, 150);
823
- helpers.markSubscriptionRateLimited("anthropic", "personal", 5_000, 200, 205);
824
- helpers.markSubscriptionRateLimited("anthropic", "personal", 10_000, 100, 300);
825
-
826
- const [entry] = helpers.readSubscriptionPool("anthropic");
499
+ const authPath = join(home, ".pi", "agent", "auth.json");
500
+ const poolPath = join(home, ".pi", "agent", "openai-codex-subscriptions.json");
501
+ mkdirSync(dirname(authPath), { recursive: true });
502
+ writeFileSync(
503
+ authPath,
504
+ `${JSON.stringify({ "openai-codex": { type: "oauth", refresh: "r-old", access: "a-old", expires: 100, accountId: "acct-old" } })}\n`,
505
+ "utf8",
506
+ );
507
+ helpers.readSubscriptionPool("openai-codex");
508
+ helpers.markSubscriptionRateLimited("openai-codex", "openai-codex", 60_000, 500, 500);
509
+ const poolBefore = readFileSync(poolPath, "utf8");
510
+ const malformed = '{"openai-codex":';
511
+ const originalWithLock = FileAuthStorageBackend.prototype.withLock;
512
+ let authLockCalls = 0;
513
+ FileAuthStorageBackend.prototype.withLock = function (fn) {
514
+ const isAuthLock = (this as unknown as { authPath: string }).authPath === authPath;
515
+ if (isAuthLock) authLockCalls += 1;
516
+ const result = originalWithLock.call(this, fn);
517
+ if (isAuthLock && authLockCalls === 1) writeFileSync(authPath, malformed, "utf8");
518
+ return result;
519
+ };
520
+ try {
521
+ assert.throws(
522
+ () =>
523
+ helpers.commitDefaultIdentity("openai-codex", {
524
+ refresh: "r-new",
525
+ access: "a-new",
526
+ expires: 200,
527
+ accountId: "acct-old",
528
+ lastRateLimitedAt: 500,
529
+ }),
530
+ SyntaxError,
531
+ );
532
+ } finally {
533
+ FileAuthStorageBackend.prototype.withLock = originalWithLock;
534
+ }
535
+ assert.equal(authLockCalls, 2, "the second auth lock is the nested persistence parse");
536
+ assert.equal(readFileSync(authPath, "utf8"), malformed);
537
+ assert.equal(readFileSync(poolPath, "utf8"), poolBefore);
538
+ const [entry] = JSON.parse(poolBefore) as helpers.SubscriptionCredential[];
827
539
  assert.deepEqual(
828
- { lastAttemptAt: entry.lastAttemptAt, lastRateLimitedAt: entry.lastRateLimitedAt, rateLimitedUntil: entry.rateLimitedUntil },
829
- { lastAttemptAt: 200, lastRateLimitedAt: 300, rateLimitedUntil: 10300 },
540
+ { lastRateLimitedAt: entry.lastRateLimitedAt, rateLimitedUntil: entry.rateLimitedUntil },
541
+ { lastRateLimitedAt: 500, rateLimitedUntil: 60_500 },
830
542
  );
831
-
832
- helpers.markSubscriptionSuccess("anthropic", "personal", 250);
833
- assert.equal(helpers.readSubscriptionPool("anthropic")[0].rateLimitedUntil, 10300);
834
- helpers.markSubscriptionSuccess("anthropic", "personal", 400);
835
- assert.equal(helpers.readSubscriptionPool("anthropic")[0].rateLimitedUntil, 0);
836
543
  });
837
544
 
838
545
  test("treats numeric subscription refs as 1-based indexes", () => {
@@ -860,7 +567,7 @@ test("switches exhausted Anthropic sessions to OpenAI Codex fallback", async ()
860
567
  model: { provider: "anthropic", id: "claude-sonnet-4-6", api: "anthropic-messages" },
861
568
  modelRegistry: {
862
569
  find(provider: string, id: string) {
863
- return provider === "openai-codex" && id === "gpt-5.4-mini" ? { provider, id, api: "openai-codex-responses" } : undefined;
570
+ return provider === "openai-codex" && id === "gpt-5.6-terra" ? { provider, id, api: "openai-codex-responses" } : undefined;
864
571
  },
865
572
  },
866
573
  } as any;
@@ -881,14 +588,14 @@ test("switches exhausted Anthropic sessions to OpenAI Codex fallback", async ()
881
588
 
882
589
  assert.deepEqual(target, {
883
590
  providerId: "openai-codex",
884
- modelId: "gpt-5.4-mini",
885
- label: "openai-codex/gpt-5.4-mini:medium",
591
+ modelId: "gpt-5.6-terra",
592
+ label: "openai-codex/gpt-5.6-terra:medium",
886
593
  strength: "medium",
887
594
  thinkingLevel: "medium",
888
595
  });
889
596
  assert.deepEqual(switchedTo, {
890
597
  provider: "openai-codex",
891
- id: "gpt-5.4-mini",
598
+ id: "gpt-5.6-terra",
892
599
  api: "openai-codex-responses",
893
600
  });
894
601
  assert.equal(thinkingLevel, "medium");
@@ -903,7 +610,7 @@ test("switches exhausted OpenAI Codex sessions to Anthropic fallback", async ()
903
610
  writeLadderConfig();
904
611
 
905
612
  const ctx = {
906
- model: { provider: "openai-codex", id: "gpt-5.5", api: "openai-codex-responses" },
613
+ model: { provider: "openai-codex", id: "gpt-5.6-sol", api: "openai-codex-responses" },
907
614
  modelRegistry: {
908
615
  find(provider: string, id: string) {
909
616
  return provider === "anthropic" && id === "claude-opus-4-8" ? { provider, id, api: "anthropic-messages" } : undefined;
@@ -940,18 +647,18 @@ test("switches exhausted OpenAI Codex sessions to Anthropic fallback", async ()
940
647
  assert.equal(thinkingLevel, "high");
941
648
  assert.deepEqual(helpers.readRotationConfig().preferredModel, {
942
649
  providerId: "openai-codex",
943
- modelId: "gpt-5.5",
650
+ modelId: "gpt-5.6-sol",
944
651
  });
945
652
  });
946
653
 
947
654
  function makeRotationCtx() {
948
655
  const ctx = {
949
- model: { provider: "openai-codex", id: "gpt-5.5", api: "openai-codex-responses" },
656
+ model: { provider: "openai-codex", id: "gpt-5.6-sol", api: "openai-codex-responses" },
950
657
  ui: { setStatus() {} },
951
658
  modelRegistry: {
952
659
  find(provider: string, id: string) {
953
660
  if (provider === "anthropic" && id === "claude-opus-4-8") return { provider, id, api: "anthropic-messages" };
954
- if (provider === "openai-codex" && id === "gpt-5.5") return { provider, id, api: "openai-codex-responses" };
661
+ if (provider === "openai-codex" && id === "gpt-5.6-sol") return { provider, id, api: "openai-codex-responses" };
955
662
  return undefined;
956
663
  },
957
664
  },
@@ -999,7 +706,7 @@ test("falls back transparently and serves the turn on Anthropic when Codex is ex
999
706
  await providerRotationExtension(pi);
1000
707
  await events.session_start?.({}, ctx);
1001
708
  const stream = providers["openai-codex"].streamSimple(
1002
- { provider: "openai-codex", id: "gpt-5.5", api: "openai-codex-responses" },
709
+ { provider: "openai-codex", id: "gpt-5.6-sol", api: "openai-codex-responses" },
1003
710
  ctx,
1004
711
  );
1005
712
  const emitted = [] as any[];
@@ -1344,6 +1051,106 @@ test("automatically reauthenticates invalid refresh tokens when UI is available"
1344
1051
  }
1345
1052
  });
1346
1053
 
1054
+ // The real invalid-refresh path must route an explicit Codex reauth through the locked identity
1055
+ // gate. A colliding OAuth result is rejected, the old credential remains intact, and the slot is
1056
+ // cooled instead of being duplicated or streamed with another slot's quota.
1057
+ test("real explicit Codex reauth rejects an account held by another slot", async () => {
1058
+ resetRotationState();
1059
+ writeLadderConfig();
1060
+ helpers.writeSubscriptionPool("openai-codex", [
1061
+ credential("stale", { expires: 1, accountId: "acct-old" }),
1062
+ credential("held", { accountId: "acct-held", rateLimitedUntil: Date.now() + 10 * 60_000 }),
1063
+ ]);
1064
+
1065
+ const { ctx, providers, events, pi } = makeRotationCtx();
1066
+ ctx.hasUI = true;
1067
+ const notes: Array<{ message: string; type?: string }> = [];
1068
+ ctx.ui.notify = (message: string, type?: string) => notes.push({ message, type });
1069
+ rotationModule.__setRefreshForProviderForTest(async (providerId) => {
1070
+ if (providerId === "openai-codex") {
1071
+ throw new Error('HTTP request failed. status=400; body={"error":"invalid_grant","error_description":"Refresh token not found or invalid"}');
1072
+ }
1073
+ throw new Error("unexpected refresh");
1074
+ });
1075
+ rotationModule.__setLoginForProviderForTest(async () => ({
1076
+ refresh: "r-collision",
1077
+ access: "a-collision",
1078
+ expires: Date.now() + 60_000,
1079
+ accountId: "acct-held",
1080
+ }));
1081
+ try {
1082
+ await providerRotationExtension(pi);
1083
+ await events.session_start?.({}, ctx);
1084
+ const stream = providers["openai-codex"].streamSimple(ctx.model, ctx);
1085
+ for await (const _event of stream) {
1086
+ // Drain the real managed-provider stream to completion.
1087
+ }
1088
+
1089
+ const pool = helpers.readSubscriptionPool("openai-codex");
1090
+ const stale = pool.find((entry) => entry.label === "stale");
1091
+ assert.equal(stale?.accountId, "acct-old");
1092
+ assert.equal(stale?.refresh, "refresh-stale");
1093
+ assert.ok((stale?.rateLimitedUntil ?? 0) > Date.now() + 29 * 24 * 60 * 60 * 1000);
1094
+ assert.equal(pool.filter((entry) => entry.accountId === "acct-held").length, 1);
1095
+ assert.ok(notes.some((note) => note.type === "error" && /re-authentication failed.*already added/i.test(note.message)));
1096
+ } finally {
1097
+ rotationModule.__setRefreshForProviderForTest(undefined);
1098
+ rotationModule.__setLoginForProviderForTest(undefined);
1099
+ }
1100
+ });
1101
+
1102
+ // The default Codex reauth branch uses the same gate before auth persistence. A colliding login
1103
+ // leaves auth.json on the original account and keeps both pool identities unique.
1104
+ test("real default Codex reauth rejects an explicit slot's account before switching auth.json", async () => {
1105
+ resetRotationState();
1106
+ writeLadderConfig();
1107
+ const authPath = join(home, ".pi", "agent", "auth.json");
1108
+ mkdirSync(dirname(authPath), { recursive: true });
1109
+ writeFileSync(
1110
+ authPath,
1111
+ `${JSON.stringify({ "openai-codex": { type: "oauth", refresh: "dead-refresh", access: "stale-access", expires: 1, accountId: "acct-old" } })}\n`,
1112
+ "utf8",
1113
+ );
1114
+ helpers.readSubscriptionPool("openai-codex");
1115
+ helpers.addSubscription("openai-codex", credential("held", { accountId: "acct-held", rateLimitedUntil: Date.now() + 10 * 60_000 }));
1116
+
1117
+ const { ctx, providers, events, pi } = makeRotationCtx();
1118
+ ctx.hasUI = true;
1119
+ const notes: Array<{ message: string; type?: string }> = [];
1120
+ ctx.ui.notify = (message: string, type?: string) => notes.push({ message, type });
1121
+ rotationModule.__setRefreshForProviderForTest(async (providerId) => {
1122
+ if (providerId === "openai-codex") {
1123
+ throw new Error('HTTP request failed. status=400; body={"error":"invalid_grant","error_description":"Refresh token not found or invalid"}');
1124
+ }
1125
+ throw new Error("unexpected refresh");
1126
+ });
1127
+ rotationModule.__setLoginForProviderForTest(async () => ({
1128
+ refresh: "r-collision",
1129
+ access: "a-collision",
1130
+ expires: Date.now() + 60_000,
1131
+ accountId: "acct-held",
1132
+ }));
1133
+ try {
1134
+ await providerRotationExtension(pi);
1135
+ await events.session_start?.({}, ctx);
1136
+ const stream = providers["openai-codex"].streamSimple(ctx.model, ctx);
1137
+ for await (const _event of stream) {
1138
+ // Drain the real managed-provider stream to completion.
1139
+ }
1140
+
1141
+ const auth = JSON.parse(readFileSync(authPath, "utf8"))["openai-codex"];
1142
+ assert.equal(auth.accountId, "acct-old");
1143
+ assert.equal(auth.refresh, "dead-refresh");
1144
+ const pool = helpers.readSubscriptionPool("openai-codex");
1145
+ assert.equal(pool.find((entry) => entry.label === "openai-codex")?.accountId, "acct-old");
1146
+ assert.equal(pool.filter((entry) => entry.accountId === "acct-held").length, 1);
1147
+ assert.ok(notes.some((note) => note.type === "error" && /re-authentication failed.*already added/i.test(note.message)));
1148
+ } finally {
1149
+ rotationModule.__setRefreshForProviderForTest(undefined);
1150
+ rotationModule.__setLoginForProviderForTest(undefined);
1151
+ }
1152
+ });
1153
+
1347
1154
  // A resolved-but-unauthenticated fallback stays on the current model and returns an
1348
1155
  // actionable "not authenticated" error when no provider is logged in.
1349
1156
  test("emits an actionable 'not authenticated' error without switching models when neither provider has ever logged in", async () => {
@@ -1377,7 +1184,7 @@ test("degrades gracefully via the builtin ladder when a config-less user has no
1377
1184
  modelRegistry: {
1378
1185
  find(provider: string, id: string) {
1379
1186
  if (provider === "anthropic" && id === "claude-opus-4-8") return { provider, id, api: "anthropic-messages" };
1380
- if (provider === "openai-codex" && id === "gpt-5.5") return { provider, id, api: "openai-codex-responses" };
1187
+ if (provider === "openai-codex" && id === "gpt-5.6-sol") return { provider, id, api: "openai-codex-responses" };
1381
1188
  return undefined;
1382
1189
  },
1383
1190
  },
@@ -1404,7 +1211,7 @@ test("degrades gracefully via the builtin ladder when a config-less user has no
1404
1211
  await providerRotationExtension(pi);
1405
1212
  await events.session_start?.({}, ctx);
1406
1213
  // Anthropic pool is empty (reset above), so the credential loop never runs. The builtin
1407
- // ladder resolves a cross-provider target (openai-codex/gpt-5.5), but that provider was
1214
+ // ladder resolves a cross-provider target (openai-codex/gpt-5.6-sol), but that provider was
1408
1215
  // never authenticated either (#123 gates fallback on the target actually being authed),
1409
1216
  // so the session model stays unchanged and the turn returns an actionable message.
1410
1217
  const stream = providers["anthropic"].streamSimple(ctx.model, ctx);
@@ -1419,16 +1226,16 @@ test("degrades gracefully via the builtin ladder when a config-less user has no
1419
1226
  });
1420
1227
 
1421
1228
  // readModelLadders() reads the builtin anthropic/openai ladder when config.json is absent.
1422
- test("resolves the builtin anthropic ladder (medium = sonnet-5) with no ~/.crouter/config.json present", () => {
1229
+ test("resolves the builtin anthropic ladder (medium = opus-4-8) with no ~/.crouter/config.json present", () => {
1423
1230
  resetRotationState();
1424
1231
  rmSync(join(home, ".crouter", "config.json"), { force: true });
1425
1232
 
1426
- const target = helpers.resolveFallbackTarget("anthropic", "anthropic/claude-sonnet-5:high");
1233
+ const target = helpers.resolveFallbackTarget("anthropic", "anthropic/claude-opus-4-8:high");
1427
1234
 
1428
1235
  assert.deepEqual(target, {
1429
1236
  providerId: "openai-codex",
1430
- modelId: "gpt-5.5",
1431
- label: "openai-codex/gpt-5.5:medium",
1237
+ modelId: "gpt-5.6-terra",
1238
+ label: "openai-codex/gpt-5.6-terra:medium",
1432
1239
  strength: "medium",
1433
1240
  thinkingLevel: "medium",
1434
1241
  });
@@ -1515,6 +1322,97 @@ test("does not wait out a cooldown longer than the sole-provider wait cap; surfa
1515
1322
  }
1516
1323
  });
1517
1324
 
1325
+ // Live-incident regression (Hearth: a home's config.json had `gpt-5.6-*` ladder rungs baked
1326
+ // in while the installed guest's model registry only had `gpt-5.5`): when the ONLY viable
1327
+ // fallback target is authenticated (its pool has a credential) but its resolved model isn't
1328
+ // in this build's registry, the terminal error must name that specific reason -- not the old
1329
+ // generic "authenticate a fallback provider" text, which is actively wrong when the real
1330
+ // blocker is a stale/out-of-sync ladder, not a missing login.
1331
+ test("surfaces a specific 'not in model registry' reason instead of misleadingly telling the user to authenticate", async () => {
1332
+ resetRotationState();
1333
+ writeLadderConfig();
1334
+ helpers.writeSubscriptionPool("anthropic", [credential("primary")]);
1335
+ helpers.markSubscriptionRateLimited("anthropic", "primary", 30 * 24 * 60 * 60 * 1000);
1336
+ // OpenAI Codex IS authenticated -- the fallback rejection must NOT be "no credentials".
1337
+ helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
1338
+
1339
+ const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
1340
+ ctx.model = { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" };
1341
+ // Registry recognizes anthropic's own model but NOT the configured openai-codex fallback
1342
+ // rung (`gpt-5.6-sol`, from writeLadderConfig) -- simulating an installed guest whose
1343
+ // catalog hasn't caught up with the configured ladder.
1344
+ ctx.modelRegistry = {
1345
+ find(provider: string, id: string) {
1346
+ return provider === "anthropic" && id === "claude-opus-4-8" ? { provider, id, api: "anthropic-messages" } : undefined;
1347
+ },
1348
+ };
1349
+ const sleepCalls: number[] = [];
1350
+ rotationModule.__setSleepForTest(async (ms) => {
1351
+ sleepCalls.push(ms);
1352
+ });
1353
+
1354
+ try {
1355
+ await providerRotationExtension(pi);
1356
+ await events.session_start?.({}, ctx);
1357
+ const stream = providers["anthropic"].streamSimple(
1358
+ { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" },
1359
+ ctx,
1360
+ );
1361
+ const emitted = [] as any[];
1362
+ for await (const event of stream) emitted.push(event);
1363
+
1364
+ assert.deepEqual(setModelCalls, []);
1365
+ assert.deepEqual(sleepCalls, []);
1366
+ assert.equal(emitted.length, 1);
1367
+ assert.equal(emitted[0].type, "error");
1368
+ assert.match(emitted[0].error.errorMessage, /openai-codex\/gpt-5\.6-sol:high is not in this build's model registry/);
1369
+ assert.match(emitted[0].error.errorMessage, /modelLadders config is out of sync with the installed model catalog/);
1370
+ assert.doesNotMatch(emitted[0].error.errorMessage, /authenticate a fallback provider/);
1371
+ // The repair command must be EXECUTABLE as printed: `--value`, the CONFIG ladder
1372
+ // provider key (`openai`, not the runtime `openai-codex`), and the target's strength.
1373
+ assert.match(
1374
+ emitted[0].error.errorMessage,
1375
+ /crtr sys config set modelLadders\.openai\.strong --value <installed-model-id>/,
1376
+ );
1377
+ } finally {
1378
+ rotationModule.__setSleepForTest(undefined);
1379
+ }
1380
+ });
1381
+
1382
+ // Every fallback-rejection discriminant maps to advice that can actually resolve IT.
1383
+ // Only `no_credentials` may say "authenticate" -- telling the user to authenticate when both
1384
+ // providers are exhausted, when no ladder rung exists, or when the model is unregistered is
1385
+ // actively wrong and was the original bug.
1386
+ test("fallback rejection hints: only no_credentials advises authentication; every other discriminant gets accurate advice", () => {
1387
+ const codex = {
1388
+ providerId: "openai-codex",
1389
+ modelId: "gpt-5.6-sol",
1390
+ label: "openai-codex/gpt-5.6-sol:high",
1391
+ strength: "strong",
1392
+ } as helpers.FallbackTarget;
1393
+
1394
+ const noCreds = rotationModule.__fallbackRejectionHintForTest("no_credentials", codex);
1395
+ assert.match(noCreds, /authenticate a fallback provider/);
1396
+ assert.match(noCreds, /OpenAI Codex/);
1397
+ assert.match(noCreds, /\/provider-sub openai-codex add/);
1398
+
1399
+ const unregistered = rotationModule.__fallbackRejectionHintForTest("model_unregistered", codex);
1400
+ assert.doesNotMatch(unregistered, /authenticate a fallback provider/);
1401
+ assert.match(unregistered, /not in this build's model registry/);
1402
+ // Executable repair command: --value, CONFIG ladder key (openai), target strength.
1403
+ assert.match(unregistered, /crtr sys config set modelLadders\.openai\.strong --value <installed-model-id>/);
1404
+
1405
+ const alreadyTried = rotationModule.__fallbackRejectionHintForTest("already_attempted", codex);
1406
+ assert.doesNotMatch(alreadyTried, /authenticate a fallback provider/);
1407
+ assert.match(alreadyTried, /already tried this turn/);
1408
+ assert.match(alreadyTried, /try again later/);
1409
+
1410
+ const noLadder = rotationModule.__fallbackRejectionHintForTest("no_ladder_entry");
1411
+ assert.doesNotMatch(noLadder, /authenticate a fallback provider/);
1412
+ assert.match(noLadder, /no cross-provider fallback is configured/);
1413
+ assert.match(noLadder, /modelLadders/);
1414
+ });
1415
+
1518
1416
  // The wait-out decision uses the pool state from the current turn, not a stale snapshot.
1519
1417
  test("waits out a sole credential's cooldown observed in THIS turn, not a stale pre-attempt pool snapshot", async () => {
1520
1418
  resetRotationState();
@@ -1578,7 +1476,7 @@ test("treats a Codex usage-limit message on a non-429 status as a rate limit: co
1578
1476
  helpers.writeSubscriptionPool("anthropic", [credential("personal")]);
1579
1477
 
1580
1478
  const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
1581
- ctx.model = { provider: "openai-codex", id: "gpt-5.5", api: "openai-codex-responses" };
1479
+ ctx.model = { provider: "openai-codex", id: "gpt-5.6-sol", api: "openai-codex-responses" };
1582
1480
  const streamedModels: any[] = [];
1583
1481
  rotationModule.__setStreamForProviderForTest((model) => {
1584
1482
  streamedModels.push(model);
@@ -1601,7 +1499,7 @@ test("treats a Codex usage-limit message on a non-429 status as a rate limit: co
1601
1499
  await providerRotationExtension(pi);
1602
1500
  await events.session_start?.({}, ctx);
1603
1501
  const stream = providers["openai-codex"].streamSimple(
1604
- { provider: "openai-codex", id: "gpt-5.5", api: "openai-codex-responses" },
1502
+ { provider: "openai-codex", id: "gpt-5.6-sol", api: "openai-codex-responses" },
1605
1503
  ctx,
1606
1504
  );
1607
1505
  const emitted = [] as any[];