@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.
- package/README.md +1 -1
- package/dist/builtin-memory/00-runtime-base.md +3 -10
- package/dist/builtin-memory/04-base-worker.md +18 -0
- package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +639 -741
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +241 -58
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
- package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +365 -94
- package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
- package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
- package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
- package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
- package/dist/builtin-views/canvas/tui.mjs +8 -9
- package/dist/builtin-views/chat/tui.mjs +12 -12
- package/dist/builtin-views/git-pr/tui.mjs +7 -8
- package/dist/builtin-views/inbox/tui.mjs +27 -41
- package/dist/builtin-views/linkedin/tui.mjs +23 -37
- package/dist/builtin-views/prompt-review/tui.mjs +11 -11
- package/dist/builtin-views/settings/tui.mjs +11 -11
- package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +1 -0
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +11 -7
- package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
- package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/mermaid-render.test.js +20 -0
- package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
- package/dist/clients/attach/attach-cmd.d.ts +1 -1
- package/dist/clients/attach/attach-cmd.js +733 -730
- package/dist/clients/attach/auth-pickers.d.ts +12 -0
- package/dist/clients/attach/auth-pickers.js +11 -0
- package/dist/clients/attach/canvas-panels.js +2 -3
- package/dist/clients/attach/chat-view.d.ts +7 -8
- package/dist/clients/attach/chat-view.js +119 -79
- package/dist/clients/attach/edit-diff-render.d.ts +6 -0
- package/dist/clients/attach/edit-diff-render.js +263 -0
- package/dist/clients/attach/input-controller.d.ts +28 -2
- package/dist/clients/attach/input-controller.js +38 -5
- package/dist/clients/attach/mermaid-render.js +5 -2
- package/dist/clients/attach/pickers.d.ts +8 -7
- package/dist/clients/attach/pickers.js +13 -17
- package/dist/clients/attach/slash-commands.d.ts +9 -0
- package/dist/clients/attach/slash-commands.js +128 -7
- package/dist/clients/attach/titled-editor.d.ts +12 -1
- package/dist/clients/attach/titled-editor.js +103 -8
- package/dist/commands/canvas-browse.js +2 -2
- package/dist/commands/memory/lint.js +39 -5
- package/dist/commands/memory/write.js +1 -0
- package/dist/commands/node.js +12 -5
- package/dist/commands/profile/new.js +30 -5
- package/dist/commands/surface-tmux-spread.js +1 -3
- package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
- package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
- package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
- package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
- package/dist/commands/sys/config.js +18 -21
- package/dist/commands/sys/setup-core.d.ts +12 -1
- package/dist/commands/sys/setup-core.js +25 -3
- package/dist/commands/sys/setup.js +166 -20
- package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
- package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
- package/dist/core/__tests__/fault-classifier.test.js +15 -0
- package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
- package/dist/core/__tests__/full/broker-pane-resolution.test.js +9 -6
- package/dist/core/__tests__/full/cascade-close.test.js +5 -2
- package/dist/core/__tests__/full/dead-pane-regression.test.js +6 -3
- package/dist/core/__tests__/full/detach-focus.test.js +10 -5
- package/dist/core/__tests__/full/human-new-window-regression.test.js +6 -3
- package/dist/core/__tests__/full/review-render-pane-regression.test.js +2 -2
- package/dist/core/__tests__/helpers/harness.d.ts +1 -0
- package/dist/core/__tests__/helpers/harness.js +28 -4
- package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
- package/dist/core/__tests__/live-mutation-verbs.test.js +5 -4
- package/dist/core/__tests__/review-model-floor.test.js +1 -1
- package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
- package/dist/core/__tests__/stream-watchdog.test.js +70 -0
- package/dist/core/__tests__/tmux-surface.test.js +72 -0
- package/dist/core/canvas/__tests__/remote-event-stream.test.js +11 -9
- package/dist/core/canvas/__tests__/render-remote.test.js +7 -4
- package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
- package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +30 -13
- package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
- package/dist/core/canvas/browse/app.js +164 -5
- package/dist/core/canvas/browse/model.d.ts +10 -6
- package/dist/core/canvas/browse/model.js +20 -12
- package/dist/core/canvas/browse/pins.d.ts +4 -0
- package/dist/core/canvas/browse/pins.js +29 -0
- package/dist/core/canvas/browse/render.d.ts +13 -1
- package/dist/core/canvas/browse/render.js +72 -15
- package/dist/core/canvas/nav-model.js +5 -11
- package/dist/core/canvas/paths.d.ts +5 -0
- package/dist/core/canvas/paths.js +7 -0
- package/dist/core/canvas/pid.d.ts +14 -10
- package/dist/core/canvas/pid.js +14 -10
- package/dist/core/config.d.ts +53 -2
- package/dist/core/config.js +228 -110
- package/dist/core/fault-classifier.js +1 -1
- package/dist/core/fs-utils.d.ts +1 -0
- package/dist/core/fs-utils.js +15 -1
- package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
- package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
- package/dist/core/keybindings/catalog.d.ts +14 -0
- package/dist/core/keybindings/catalog.js +256 -0
- package/dist/core/keybindings/index.d.ts +5 -0
- package/dist/core/keybindings/index.js +4 -0
- package/dist/core/keybindings/match.d.ts +29 -0
- package/dist/core/keybindings/match.js +99 -0
- package/dist/core/keybindings/persistence.d.ts +14 -0
- package/dist/core/keybindings/persistence.js +31 -0
- package/dist/core/keybindings/resolve.d.ts +14 -0
- package/dist/core/keybindings/resolve.js +305 -0
- package/dist/core/keybindings/types.d.ts +37 -0
- package/dist/core/keybindings/types.js +1 -0
- package/dist/core/memory-resolver.d.ts +1 -1
- package/dist/core/memory-resolver.js +10 -3
- package/dist/core/predicate.d.ts +5 -3
- package/dist/core/predicate.js +5 -3
- package/dist/core/runtime/broker-protocol.d.ts +33 -5
- package/dist/core/runtime/broker.js +165 -14
- package/dist/core/runtime/launch.d.ts +39 -6
- package/dist/core/runtime/launch.js +79 -21
- package/dist/core/runtime/naming.js +3 -3
- package/dist/core/runtime/pi-cli.d.ts +6 -0
- package/dist/core/runtime/pi-cli.js +16 -2
- package/dist/core/runtime/placement.d.ts +2 -2
- package/dist/core/runtime/placement.js +4 -1
- package/dist/core/runtime/promote.js +4 -0
- package/dist/core/runtime/recap.d.ts +3 -3
- package/dist/core/runtime/recap.js +47 -44
- package/dist/core/runtime/recycle.js +6 -1
- package/dist/core/runtime/reset.js +5 -0
- package/dist/core/runtime/revive.js +28 -18
- package/dist/core/runtime/session-list-cache.d.ts +23 -0
- package/dist/core/runtime/session-list-cache.js +270 -0
- package/dist/core/runtime/spawn.js +51 -32
- package/dist/core/runtime/stream-watchdog.d.ts +26 -0
- package/dist/core/runtime/stream-watchdog.js +75 -0
- package/dist/core/runtime/tmux-chrome.d.ts +1 -1
- package/dist/core/runtime/tmux-chrome.js +2 -2
- package/dist/core/runtime/tmux.d.ts +18 -15
- package/dist/core/runtime/tmux.js +216 -110
- package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
- package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
- package/dist/core/tui/host.d.ts +30 -1
- package/dist/core/tui/host.js +148 -34
- package/dist/core/view/__tests__/transport-remote.test.js +9 -6
- package/dist/core/view/contract.d.ts +17 -3
- package/dist/daemon/crtrd-cli.d.ts +1 -1
- package/dist/daemon/crtrd-cli.js +1 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
- package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
- package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
- package/dist/pi-extensions/canvas-recap.d.ts +4 -0
- package/dist/pi-extensions/canvas-recap.js +38 -37
- package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
- package/dist/pi-extensions/canvas-stophook.js +33 -4
- package/dist/suppress-experimental-warnings.d.ts +1 -0
- package/dist/suppress-experimental-warnings.js +15 -0
- package/dist/types.d.ts +3 -13
- package/dist/types.js +12 -36
- package/dist/web-client/assets/{index-CnF5r8ky.js → index--md2ylfi.js} +18 -17
- package/dist/web-client/assets/index-BUdm9s9s.css +2 -0
- package/dist/web-client/index.html +3 -3
- package/dist/web-client/sw.js +1 -1
- package/package.json +11 -8
- package/scripts/postinstall.mjs +1 -1
- package/dist/pi-extensions/canvas-nav.d.ts +0 -43
- package/dist/pi-extensions/canvas-nav.js +0 -640
- package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
- package/dist/pi-extensions/widget-order-bus.js +0 -34
- package/dist/web-client/assets/index-BnmSLNLa.css +0 -2
package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts
CHANGED
|
@@ -1,838 +1,545 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
let
|
|
9
|
-
let
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
263
|
-
assert.
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
assert.
|
|
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.
|
|
107
|
+
rotationModule.__setLoginForProviderForTest(undefined);
|
|
270
108
|
}
|
|
271
109
|
});
|
|
272
110
|
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
// the
|
|
276
|
-
|
|
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
|
-
|
|
116
|
+
helpers.addSubscription("openai-codex", credential("work", { accountId: "acct-X" }));
|
|
281
117
|
const authPath = join(home, ".pi", "agent", "auth.json");
|
|
282
|
-
|
|
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
|
-
|
|
296
|
-
ctx.ui.notify = () => {};
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
assert.
|
|
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
|
-
//
|
|
346
|
-
//
|
|
347
|
-
test("
|
|
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
|
-
|
|
360
|
-
helpers.readSubscriptionPool("anthropic");
|
|
148
|
+
helpers.readSubscriptionPool("openai-codex");
|
|
361
149
|
|
|
362
|
-
const { ctx,
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
);
|
|
387
|
-
|
|
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
|
-
//
|
|
409
|
-
//
|
|
410
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
//
|
|
526
|
-
//
|
|
527
|
-
|
|
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("
|
|
539
|
-
helpers.
|
|
540
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
//
|
|
565
|
-
//
|
|
566
|
-
test("
|
|
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("
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
|
|
581
|
-
assert.
|
|
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
|
-
//
|
|
587
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
603
|
-
//
|
|
604
|
-
test("
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
-
|
|
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
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
assert.deepEqual(helpers.
|
|
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
|
-
//
|
|
749
|
-
//
|
|
750
|
-
|
|
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
|
-
|
|
753
|
-
const
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
-
{
|
|
829
|
-
{
|
|
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.
|
|
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.
|
|
885
|
-
label: "openai-codex/gpt-5.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 =
|
|
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-
|
|
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.
|
|
1431
|
-
label: "openai-codex/gpt-5.
|
|
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.
|
|
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.
|
|
1502
|
+
{ provider: "openai-codex", id: "gpt-5.6-sol", api: "openai-codex-responses" },
|
|
1605
1503
|
ctx,
|
|
1606
1504
|
);
|
|
1607
1505
|
const emitted = [] as any[];
|