@crouton-kit/crouter 0.3.78 → 0.3.80
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/dist/build-root.d.ts +12 -4
- package/dist/build-root.js +25 -6
- package/dist/builtin-memory/crouter-development/plugins.md +82 -5
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1228 -12
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +3 -3
- package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -787
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +8 -3
- package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
- package/dist/clients/attach/__tests__/mermaid-render.test.js +9 -1
- package/dist/clients/attach/attach-cmd.d.ts +9 -1
- package/dist/clients/attach/attach-cmd.js +847 -801
- package/dist/clients/attach/auth-pickers.d.ts +0 -12
- package/dist/clients/attach/auth-pickers.js +64 -15
- package/dist/clients/attach/chat-view.d.ts +4 -0
- package/dist/clients/attach/chat-view.js +10 -0
- package/dist/clients/attach/graph-overlay.d.ts +12 -2
- package/dist/clients/attach/graph-overlay.js +83 -33
- package/dist/clients/attach/input-controller.d.ts +19 -0
- package/dist/clients/attach/input-controller.js +55 -11
- package/dist/clients/attach/titled-editor.d.ts +21 -0
- package/dist/clients/attach/titled-editor.js +101 -0
- package/dist/commands/human/queue.js +3 -4
- package/dist/commands/pkg/plugin-inspect.js +22 -1
- package/dist/commands/pkg/plugin-manage.js +31 -9
- package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
- package/dist/commands/sys/doctor.js +42 -4
- package/dist/commands/sys/setup-core.d.ts +37 -0
- package/dist/commands/sys/setup-core.js +138 -1
- package/dist/commands/sys/setup.d.ts +88 -0
- package/dist/commands/sys/setup.js +915 -171
- package/dist/commands/view-pick.d.ts +4 -0
- package/dist/commands/view-pick.js +17 -7
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
- package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
- package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
- package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
- package/dist/core/__tests__/command-plugins.test.js +444 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
- package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
- package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
- package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
- package/dist/core/canvas/browse/app.d.ts +6 -0
- package/dist/core/canvas/browse/app.js +71 -41
- package/dist/core/command-plugins/adapter.d.ts +15 -0
- package/dist/core/command-plugins/adapter.js +145 -0
- package/dist/core/command-plugins/compose.d.ts +5 -0
- package/dist/core/command-plugins/compose.js +56 -0
- package/dist/core/command-plugins/discovery.d.ts +104 -0
- package/dist/core/command-plugins/discovery.js +565 -0
- package/dist/core/config.d.ts +2 -5
- package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
- package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
- package/dist/core/keybindings/__tests__/resolve.test.js +2 -2
- package/dist/core/keybindings/catalog.d.ts +3 -3
- package/dist/core/keybindings/catalog.js +2 -1
- package/dist/core/profiles/select.d.ts +6 -0
- package/dist/core/profiles/select.js +90 -59
- package/dist/core/provider-management.d.ts +12 -0
- package/dist/core/provider-management.js +24 -0
- package/dist/core/runtime/banner.d.ts +13 -0
- package/dist/core/runtime/banner.js +51 -0
- package/dist/core/runtime/broker.js +7 -6
- package/dist/core/runtime/pi-vendored.d.ts +8 -0
- package/dist/core/runtime/pi-vendored.js +14 -0
- package/dist/core/runtime/recap.d.ts +1 -1
- package/dist/core/runtime/recap.js +50 -25
- package/dist/core/runtime/session-list-cache.d.ts +10 -0
- package/dist/core/runtime/session-list-cache.js +94 -26
- package/dist/core/runtime/spawn.js +5 -17
- package/dist/core/runtime/tmux.js +2 -1
- package/dist/core/scope.js +27 -4
- package/dist/core/subscription-state.d.ts +90 -0
- package/dist/core/subscription-state.js +762 -0
- package/dist/daemon/crtrd.js +253 -12
- package/dist/pi-extensions/canvas-recap.js +43 -17
- package/dist/types.d.ts +6 -13
- package/dist/types.js +0 -3
- package/package.json +7 -3
package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts
CHANGED
|
@@ -1,3 +1,1084 @@
|
|
|
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, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { FileAuthStorageBackend } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
|
|
9
|
+
let home: string;
|
|
10
|
+
let helpers: typeof import("../../lib/subscription-state.ts");
|
|
11
|
+
let providerManagement: typeof import("../../../../core/provider-management.ts");
|
|
12
|
+
let rotationModule: typeof import("../provider-rotation.ts");
|
|
13
|
+
let providerRotationExtension: typeof import("../provider-rotation.ts").default;
|
|
14
|
+
const previousHome = process.env.HOME;
|
|
15
|
+
|
|
16
|
+
function writeLadderConfig() {
|
|
17
|
+
const configPath = join(home, ".crouter", "config.json");
|
|
18
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
19
|
+
writeFileSync(
|
|
20
|
+
configPath,
|
|
21
|
+
`${JSON.stringify(
|
|
22
|
+
{
|
|
23
|
+
modelLadders: {
|
|
24
|
+
anthropic: {
|
|
25
|
+
ultra: "anthropic/claude-fable-5:high",
|
|
26
|
+
strong: "anthropic/claude-opus-4-8:high",
|
|
27
|
+
medium: "anthropic/claude-sonnet-4-6:high",
|
|
28
|
+
light: "anthropic/custom-light-x",
|
|
29
|
+
},
|
|
30
|
+
openai: {
|
|
31
|
+
ultra: "openai-codex/gpt-5.6-sol:max",
|
|
32
|
+
strong: "openai-codex/gpt-5.6-sol:high",
|
|
33
|
+
medium: "openai-codex/gpt-5.6-terra:medium",
|
|
34
|
+
light: "openai-codex/gpt-5.6-luna:low",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
null,
|
|
39
|
+
2,
|
|
40
|
+
)}\n`,
|
|
41
|
+
"utf8",
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function credential(label: string, overrides: Partial<helpers.SubscriptionCredential> = {}): helpers.SubscriptionCredential {
|
|
46
|
+
return {
|
|
47
|
+
label,
|
|
48
|
+
refresh: `refresh-${label}`,
|
|
49
|
+
access: `access-${label}`,
|
|
50
|
+
expires: Date.now() + 60_000,
|
|
51
|
+
rateLimitedUntil: 0,
|
|
52
|
+
lastAttemptAt: 0,
|
|
53
|
+
lastRateLimitedAt: 0,
|
|
54
|
+
...overrides,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resetRotationState() {
|
|
59
|
+
rmSync(join(home, ".pi", "agent", "auth.json"), { force: true });
|
|
60
|
+
helpers.writeSubscriptionPool("anthropic", []);
|
|
61
|
+
helpers.writeSubscriptionPool("openai-codex", []);
|
|
62
|
+
helpers.clearPreferredModel();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function makeAuthFileReadOnly(authPath: string): void {
|
|
66
|
+
chmodSync(authPath, 0o444);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function makeAuthFileWritable(authPath: string): void {
|
|
70
|
+
chmodSync(authPath, 0o600);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function observeLines(stream: NodeJS.ReadableStream, onLine: (line: string) => void): void {
|
|
74
|
+
let buffer = "";
|
|
75
|
+
stream.setEncoding("utf8");
|
|
76
|
+
stream.on("data", (chunk: string) => {
|
|
77
|
+
buffer += chunk;
|
|
78
|
+
let newlineIndex = buffer.indexOf("\n");
|
|
79
|
+
while (newlineIndex !== -1) {
|
|
80
|
+
const line = buffer.slice(0, newlineIndex);
|
|
81
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
82
|
+
onLine(line);
|
|
83
|
+
newlineIndex = buffer.indexOf("\n");
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function within<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
|
89
|
+
return new Promise<T>((resolve, reject) => {
|
|
90
|
+
const timer = setTimeout(() => reject(new Error(message)), ms);
|
|
91
|
+
promise.then(
|
|
92
|
+
(value) => {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
resolve(value);
|
|
95
|
+
},
|
|
96
|
+
(error: unknown) => {
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
reject(error);
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
before(async () => {
|
|
105
|
+
home = mkdtempSync(join(tmpdir(), "pi-provider-rotation-"));
|
|
106
|
+
process.env.HOME = home;
|
|
107
|
+
helpers = await import("../../lib/subscription-state.ts");
|
|
108
|
+
providerManagement = await import("../../../../core/provider-management.ts");
|
|
109
|
+
rotationModule = await import("../provider-rotation.ts");
|
|
110
|
+
providerRotationExtension = rotationModule.default;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
after(() => {
|
|
114
|
+
if (previousHome === undefined) delete process.env.HOME;
|
|
115
|
+
else process.env.HOME = previousHome;
|
|
116
|
+
rmSync(home, { recursive: true, force: true });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("normalizes provider subscription pool files", () => {
|
|
120
|
+
resetRotationState();
|
|
121
|
+
helpers.writeSubscriptionPool("anthropic", [
|
|
122
|
+
{
|
|
123
|
+
label: " personal ",
|
|
124
|
+
refresh: " refresh-token ",
|
|
125
|
+
access: " access-token ",
|
|
126
|
+
expires: 123,
|
|
127
|
+
rateLimitedUntil: Number.NaN,
|
|
128
|
+
lastAttemptAt: Number.NaN,
|
|
129
|
+
lastRateLimitedAt: Number.NaN,
|
|
130
|
+
},
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
assert.deepEqual(helpers.readSubscriptionPool("anthropic"), [
|
|
134
|
+
{
|
|
135
|
+
label: "personal",
|
|
136
|
+
refresh: "refresh-token",
|
|
137
|
+
access: "access-token",
|
|
138
|
+
expires: 123,
|
|
139
|
+
rateLimitedUntil: 0,
|
|
140
|
+
lastAttemptAt: 0,
|
|
141
|
+
lastRateLimitedAt: 0,
|
|
142
|
+
},
|
|
143
|
+
]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// The default label's pool entry is metadata-only -- seeding it from auth.json must never
|
|
147
|
+
// copy a token into the pool, only bootstrap the zeroed cooldown bookkeeping that has
|
|
148
|
+
// nowhere else to live. Assert via the literal key set (not just "the value fields are
|
|
149
|
+
// undefined") so a regression that starts writing `refresh: undefined` etc. -- which would
|
|
150
|
+
// still satisfy an `undefined`-only check -- is caught.
|
|
151
|
+
test("fresh-node seeding of the default slot is metadata-only (no value keys present at all)", () => {
|
|
152
|
+
resetRotationState();
|
|
153
|
+
const authPath = join(home, ".pi", "agent", "auth.json");
|
|
154
|
+
mkdirSync(dirname(authPath), { recursive: true });
|
|
155
|
+
writeFileSync(
|
|
156
|
+
authPath,
|
|
157
|
+
`${JSON.stringify({
|
|
158
|
+
"openai-codex": {
|
|
159
|
+
type: "oauth",
|
|
160
|
+
refresh: "refresh-codex-auth",
|
|
161
|
+
access: "access-codex-auth",
|
|
162
|
+
expires: 123456,
|
|
163
|
+
},
|
|
164
|
+
})}\n`,
|
|
165
|
+
"utf8",
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const pool = helpers.readSubscriptionPool("openai-codex");
|
|
169
|
+
assert.equal(pool.length, 1);
|
|
170
|
+
assert.deepEqual(Object.keys(pool[0]).sort(), ["label", "lastAttemptAt", "lastRateLimitedAt", "rateLimitedUntil"]);
|
|
171
|
+
assert.deepEqual(pool[0], {
|
|
172
|
+
label: "openai-codex",
|
|
173
|
+
rateLimitedUntil: 0,
|
|
174
|
+
lastAttemptAt: 0,
|
|
175
|
+
lastRateLimitedAt: 0,
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// There is exactly one place the default slot's value is ever stored -- auth.json -- so
|
|
180
|
+
// there is no second copy in the pool to ever diverge from it. Prove this directly: seed
|
|
181
|
+
// the pool from auth.json holding credential A, then overwrite auth.json with a DIFFERENT
|
|
182
|
+
// credential B (simulating an external refresh/re-login the pool never sees), and read the
|
|
183
|
+
// pool again. It must still be the SAME metadata-only entry, unchanged by B -- because
|
|
184
|
+
// there was never a value in the pool to go stale in the first place.
|
|
185
|
+
test("no divergence is possible: overwriting auth.json elsewhere never changes the metadata-only pool entry", () => {
|
|
186
|
+
resetRotationState();
|
|
187
|
+
const authPath = join(home, ".pi", "agent", "auth.json");
|
|
188
|
+
mkdirSync(dirname(authPath), { recursive: true });
|
|
189
|
+
writeFileSync(
|
|
190
|
+
authPath,
|
|
191
|
+
`${JSON.stringify({
|
|
192
|
+
anthropic: { type: "oauth", refresh: "refresh-A", access: "access-A", expires: 123456 },
|
|
193
|
+
})}\n`,
|
|
194
|
+
"utf8",
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const first = helpers.readSubscriptionPool("anthropic");
|
|
198
|
+
assert.deepEqual(first, [{ label: "anthropic", rateLimitedUntil: 0, lastAttemptAt: 0, lastRateLimitedAt: 0 }]);
|
|
199
|
+
|
|
200
|
+
writeFileSync(
|
|
201
|
+
authPath,
|
|
202
|
+
`${JSON.stringify({
|
|
203
|
+
anthropic: { type: "oauth", refresh: "refresh-B", access: "access-B", expires: 999999 },
|
|
204
|
+
})}\n`,
|
|
205
|
+
"utf8",
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const second = helpers.readSubscriptionPool("anthropic");
|
|
209
|
+
assert.deepEqual(second, first);
|
|
210
|
+
assert.deepEqual(Object.keys(second[0]).sort(), ["label", "lastAttemptAt", "lastRateLimitedAt", "rateLimitedUntil"]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// The default label's pool entry is metadata-only on disk: reading it strips any stored
|
|
214
|
+
// refresh, access, and expires fields and persists the cleaned pool immediately.
|
|
215
|
+
test("scrubbing the default-slot value on read persists the metadata-only pool to disk", () => {
|
|
216
|
+
resetRotationState();
|
|
217
|
+
const poolFile = join(home, ".pi", "agent", "anthropic-subscriptions.json");
|
|
218
|
+
mkdirSync(dirname(poolFile), { recursive: true });
|
|
219
|
+
writeFileSync(
|
|
220
|
+
poolFile,
|
|
221
|
+
`${JSON.stringify([
|
|
222
|
+
{ label: "anthropic", refresh: "stale-refresh", access: "stale-access", expires: 123, rateLimitedUntil: 0, lastAttemptAt: 0, lastRateLimitedAt: 0 },
|
|
223
|
+
])}\n`,
|
|
224
|
+
"utf8",
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const pool = helpers.readSubscriptionPool("anthropic");
|
|
228
|
+
assert.deepEqual(Object.keys(pool[0]).sort(), ["label", "lastAttemptAt", "lastRateLimitedAt", "rateLimitedUntil"]);
|
|
229
|
+
|
|
230
|
+
// The raw FILE on disk must ALSO be scrubbed -- not just the in-memory return value.
|
|
231
|
+
const raw = JSON.parse(readFileSync(poolFile, "utf8"));
|
|
232
|
+
assert.deepEqual(Object.keys(raw[0]).sort(), ["label", "lastAttemptAt", "lastRateLimitedAt", "rateLimitedUntil"]);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// The brick-repro (root cause of the original bug): two concurrent turns both see the
|
|
236
|
+
// SAME expired default-slot credential and both try to refresh it. Without a real lock
|
|
237
|
+
// serializing read -> decide -> refresh -> write, both call the (single-use) refresh token
|
|
238
|
+
// and the loser gets a genuine invalid_grant from the provider -- bricking that turn. Prove
|
|
239
|
+
// the fix: the injected refresh fn is called EXACTLY ONCE across two concurrent callers;
|
|
240
|
+
// the second caller re-reads the winner's fresh value under the lock and skips its own
|
|
241
|
+
// network call entirely; both callers resolve without throwing; auth.json ends up holding
|
|
242
|
+
// exactly the one refreshed value; the pool entry stays metadata-only throughout.
|
|
243
|
+
//
|
|
244
|
+
// This test is proven to fail without the fix, not just asserted to pass with it -- see
|
|
245
|
+
// the fail-then-pass evidence recorded in the implementation report.
|
|
246
|
+
test("brick-repro: concurrent refreshes of the same expired default-slot token serialize through the lock (exactly one network refresh)", async () => {
|
|
247
|
+
resetRotationState();
|
|
248
|
+
const authPath = join(home, ".pi", "agent", "auth.json");
|
|
249
|
+
mkdirSync(dirname(authPath), { recursive: true });
|
|
250
|
+
writeFileSync(
|
|
251
|
+
authPath,
|
|
252
|
+
`${JSON.stringify({
|
|
253
|
+
anthropic: { type: "oauth", refresh: "shared-refresh", access: "access-stale", expires: 1 },
|
|
254
|
+
})}\n`,
|
|
255
|
+
"utf8",
|
|
256
|
+
);
|
|
257
|
+
const poolEntry = helpers.readSubscriptionPool("anthropic")[0];
|
|
258
|
+
assert.equal(poolEntry.label, "anthropic");
|
|
259
|
+
|
|
260
|
+
let refreshCalls = 0;
|
|
261
|
+
rotationModule.__setRefreshForProviderForTest(async (_providerId, refreshToken) => {
|
|
262
|
+
refreshCalls += 1;
|
|
263
|
+
if (refreshCalls === 1) {
|
|
264
|
+
return { refresh: "shared-refresh-rotated", access: "access-fresh", expires: Date.now() + 60_000 };
|
|
265
|
+
}
|
|
266
|
+
// Any refresh call after the first is simulating "this refresh token was already
|
|
267
|
+
// consumed by the winner" -- a genuine invalid_grant from the provider.
|
|
268
|
+
throw Object.assign(new Error(`HTTP request failed. status=400; body={"error":"invalid_grant"} for token ${refreshToken}`), { status: 400 });
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
const [a, b] = await Promise.all([
|
|
273
|
+
rotationModule.__refreshCredentialIfNeededForTest("anthropic", poolEntry),
|
|
274
|
+
rotationModule.__refreshCredentialIfNeededForTest("anthropic", poolEntry),
|
|
275
|
+
]);
|
|
276
|
+
|
|
277
|
+
assert.equal(refreshCalls, 1);
|
|
278
|
+
assert.equal(a.access, "access-fresh");
|
|
279
|
+
assert.equal(b.access, "access-fresh");
|
|
280
|
+
|
|
281
|
+
const stillMetadataOnly = helpers.readSubscriptionPool("anthropic")[0];
|
|
282
|
+
assert.deepEqual(Object.keys(stillMetadataOnly).sort(), ["label", "lastAttemptAt", "lastRateLimitedAt", "rateLimitedUntil"]);
|
|
283
|
+
|
|
284
|
+
const authRaw = JSON.parse(readFileSync(authPath, "utf8"));
|
|
285
|
+
assert.equal(authRaw.anthropic.access, "access-fresh");
|
|
286
|
+
assert.equal(authRaw.anthropic.refresh, "shared-refresh-rotated");
|
|
287
|
+
} finally {
|
|
288
|
+
rotationModule.__setRefreshForProviderForTest(undefined);
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// A GENUINE invalid_grant (the refresh token is actually dead, not merely raced) must
|
|
293
|
+
// still reach the real cooldown -> reauth path through the new lock-delegated seam --
|
|
294
|
+
// the fix must not accidentally swallow or misclassify a real failure. Drives it through
|
|
295
|
+
// the actual provider stream (not the raw helper) so `isInvalidRefreshTokenError` +
|
|
296
|
+
// reauthentication + the AuthStorage persist all fire for real.
|
|
297
|
+
test("a genuine invalid_grant through the default-slot lock still reauthenticates and persists to auth.json", async () => {
|
|
298
|
+
resetRotationState();
|
|
299
|
+
writeLadderConfig();
|
|
300
|
+
const authPath = join(home, ".pi", "agent", "auth.json");
|
|
301
|
+
mkdirSync(dirname(authPath), { recursive: true });
|
|
302
|
+
writeFileSync(
|
|
303
|
+
authPath,
|
|
304
|
+
`${JSON.stringify({
|
|
305
|
+
anthropic: { type: "oauth", refresh: "dead-refresh", access: "access-stale", expires: 1 },
|
|
306
|
+
})}\n`,
|
|
307
|
+
"utf8",
|
|
308
|
+
);
|
|
309
|
+
// Seed the metadata-only pool entry for the default label.
|
|
310
|
+
helpers.readSubscriptionPool("anthropic");
|
|
311
|
+
|
|
312
|
+
const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
|
|
313
|
+
ctx.hasUI = true;
|
|
314
|
+
ctx.model = { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" };
|
|
315
|
+
ctx.ui.notify = () => {};
|
|
316
|
+
const loginCalls: string[] = [];
|
|
317
|
+
// Plain Error, no typed .status: real pi-ai refresh failures carry the HTTP status
|
|
318
|
+
// in the message text, so the refresh path still recognizes an invalid_grant from this
|
|
319
|
+
// shape.
|
|
320
|
+
rotationModule.__setRefreshForProviderForTest(async (providerId) => {
|
|
321
|
+
if (providerId === "anthropic") {
|
|
322
|
+
throw new Error('HTTP request failed. status=400; body={"error":"invalid_grant","error_description":"Refresh token not found or invalid"}');
|
|
323
|
+
}
|
|
324
|
+
throw new Error("unexpected refresh");
|
|
325
|
+
});
|
|
326
|
+
rotationModule.__setLoginForProviderForTest(async (providerId) => {
|
|
327
|
+
loginCalls.push(providerId);
|
|
328
|
+
return { refresh: "refresh-reauthed", access: "access-reauthed", expires: Date.now() + 60_000 };
|
|
329
|
+
});
|
|
330
|
+
rotationModule.__setStreamForProviderForTest((_model, _context, options) => {
|
|
331
|
+
return (async function* () {
|
|
332
|
+
yield { type: "text_delta", text: `hello with ${options?.apiKey}` };
|
|
333
|
+
})() as any;
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
await providerRotationExtension(pi);
|
|
338
|
+
await events.session_start?.({}, ctx);
|
|
339
|
+
const stream = providers["anthropic"].streamSimple({ provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" }, ctx);
|
|
340
|
+
const emitted = [] as any[];
|
|
341
|
+
for await (const event of stream) emitted.push(event);
|
|
342
|
+
|
|
343
|
+
assert.deepEqual(loginCalls, ["anthropic"]);
|
|
344
|
+
assert.deepEqual(setModelCalls, []);
|
|
345
|
+
assert.ok(emitted.some((e) => e.type === "text_delta"));
|
|
346
|
+
assert.ok(!emitted.some((e) => e.type === "error"));
|
|
347
|
+
|
|
348
|
+
// Reauth persisted to auth.json (the single owner), not the pool.
|
|
349
|
+
const authRaw = JSON.parse(readFileSync(authPath, "utf8"));
|
|
350
|
+
assert.equal(authRaw.anthropic.refresh, "refresh-reauthed");
|
|
351
|
+
assert.equal(authRaw.anthropic.access, "access-reauthed");
|
|
352
|
+
|
|
353
|
+
// Pool stays metadata-only after reauth too, with the cooldown cleared.
|
|
354
|
+
const [entry] = helpers.readSubscriptionPool("anthropic");
|
|
355
|
+
assert.deepEqual(Object.keys(entry).sort(), ["label", "lastAttemptAt", "lastRateLimitedAt", "rateLimitedUntil"]);
|
|
356
|
+
assert.equal(entry.rateLimitedUntil, 0);
|
|
357
|
+
} finally {
|
|
358
|
+
rotationModule.__setRefreshForProviderForTest(undefined);
|
|
359
|
+
rotationModule.__setLoginForProviderForTest(undefined);
|
|
360
|
+
rotationModule.__setStreamForProviderForTest(undefined);
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// A locked auth.json write failure must not let default-slot reauth look successful or
|
|
365
|
+
// clear the pool's cooldown bookkeeping.
|
|
366
|
+
test("a locked default-slot reauth write failure leaves the pool cooled down and surfaces an error", async () => {
|
|
367
|
+
resetRotationState();
|
|
368
|
+
writeLadderConfig();
|
|
369
|
+
const authPath = join(home, ".pi", "agent", "auth.json");
|
|
370
|
+
mkdirSync(dirname(authPath), { recursive: true });
|
|
371
|
+
writeFileSync(
|
|
372
|
+
authPath,
|
|
373
|
+
`${JSON.stringify({
|
|
374
|
+
anthropic: { type: "oauth", refresh: "dead-refresh", access: "access-stale", expires: 1 },
|
|
375
|
+
})}\n`,
|
|
376
|
+
"utf8",
|
|
377
|
+
);
|
|
378
|
+
// Seed the metadata-only default pool entry for the default label.
|
|
379
|
+
helpers.readSubscriptionPool("anthropic");
|
|
380
|
+
|
|
381
|
+
const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
|
|
382
|
+
ctx.hasUI = true;
|
|
383
|
+
ctx.model = { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" };
|
|
384
|
+
ctx.ui.notify = () => {};
|
|
385
|
+
rotationModule.__setRefreshForProviderForTest(async (providerId) => {
|
|
386
|
+
if (providerId === "anthropic") {
|
|
387
|
+
throw new Error('HTTP request failed. status=400; body={"error":"invalid_grant","error_description":"Refresh token not found or invalid"}');
|
|
388
|
+
}
|
|
389
|
+
throw new Error("unexpected refresh");
|
|
390
|
+
});
|
|
391
|
+
rotationModule.__setLoginForProviderForTest(async () => ({ refresh: "refresh-reauthed", access: "access-reauthed", expires: Date.now() + 60_000 }));
|
|
392
|
+
rotationModule.__setStreamForProviderForTest((_model, _context, options) => {
|
|
393
|
+
return (async function* () {
|
|
394
|
+
yield { type: "text_delta", text: `hello with ${options?.apiKey}` };
|
|
395
|
+
})() as any;
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
try {
|
|
399
|
+
makeAuthFileReadOnly(authPath);
|
|
400
|
+
await providerRotationExtension(pi);
|
|
401
|
+
await events.session_start?.({}, ctx);
|
|
402
|
+
const stream = providers["anthropic"].streamSimple(
|
|
403
|
+
{ provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" },
|
|
404
|
+
ctx,
|
|
405
|
+
);
|
|
406
|
+
const emitted = [] as any[];
|
|
407
|
+
for await (const event of stream) emitted.push(event);
|
|
408
|
+
|
|
409
|
+
assert.deepEqual(setModelCalls, []);
|
|
410
|
+
assert.ok(emitted.some((e) => e.type === "error"));
|
|
411
|
+
assert.ok(!emitted.some((e) => e.type === "text_delta"));
|
|
412
|
+
|
|
413
|
+
const authRaw = JSON.parse(readFileSync(authPath, "utf8"));
|
|
414
|
+
assert.equal(authRaw.anthropic.refresh, "dead-refresh");
|
|
415
|
+
|
|
416
|
+
const [entry] = helpers.readSubscriptionPool("anthropic");
|
|
417
|
+
assert.equal(entry.label, "anthropic");
|
|
418
|
+
assert.ok(entry.rateLimitedUntil > Date.now() + 29 * 24 * 60 * 60 * 1000);
|
|
419
|
+
} finally {
|
|
420
|
+
makeAuthFileWritable(authPath);
|
|
421
|
+
rotationModule.__setRefreshForProviderForTest(undefined);
|
|
422
|
+
rotationModule.__setLoginForProviderForTest(undefined);
|
|
423
|
+
rotationModule.__setStreamForProviderForTest(undefined);
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// A genuine invalid_grant through the default-slot lock seam cools the credential down
|
|
428
|
+
// and falls back cross-provider when UI is unavailable: expired OAuth in auth.json, a
|
|
429
|
+
// metadata-only default pool entry, an authenticated Codex fallback, and the real
|
|
430
|
+
// streamSimple path all participate.
|
|
431
|
+
test("a genuine invalid_grant through the default-slot lock with no UI reaches cooldown and falls back cross-provider", async () => {
|
|
432
|
+
resetRotationState();
|
|
433
|
+
writeLadderConfig();
|
|
434
|
+
const authPath = join(home, ".pi", "agent", "auth.json");
|
|
435
|
+
mkdirSync(dirname(authPath), { recursive: true });
|
|
436
|
+
writeFileSync(
|
|
437
|
+
authPath,
|
|
438
|
+
`${JSON.stringify({
|
|
439
|
+
anthropic: { type: "oauth", refresh: "dead-refresh", access: "access-stale", expires: 1 },
|
|
440
|
+
})}\n`,
|
|
441
|
+
"utf8",
|
|
442
|
+
);
|
|
443
|
+
// Seed the metadata-only default pool entry for anthropic -- the seam under test.
|
|
444
|
+
helpers.readSubscriptionPool("anthropic");
|
|
445
|
+
// Authenticated Codex fallback already sitting in its own pool.
|
|
446
|
+
helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
|
|
447
|
+
|
|
448
|
+
const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
|
|
449
|
+
ctx.model = { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" };
|
|
450
|
+
// ctx.hasUI is deliberately left falsy: reauth must be impossible, so the only path
|
|
451
|
+
// forward is cooldown + cross-provider fallback.
|
|
452
|
+
const streamedModels: any[] = [];
|
|
453
|
+
rotationModule.__setRefreshForProviderForTest(async (providerId) => {
|
|
454
|
+
if (providerId === "anthropic") {
|
|
455
|
+
// Plain Error, no typed .status: the refresh path reads the invalid_grant shape from
|
|
456
|
+
// the message text, matching production failures.
|
|
457
|
+
throw new Error('HTTP request failed. status=400; body={"error":"invalid_grant","error_description":"Refresh token not found or invalid"}');
|
|
458
|
+
}
|
|
459
|
+
throw new Error("unexpected refresh");
|
|
460
|
+
});
|
|
461
|
+
rotationModule.__setStreamForProviderForTest((model) => {
|
|
462
|
+
streamedModels.push(model);
|
|
463
|
+
return (async function* () {
|
|
464
|
+
yield { type: "text_delta", text: "hello from codex fallback" };
|
|
465
|
+
})() as any;
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
try {
|
|
469
|
+
await providerRotationExtension(pi);
|
|
470
|
+
await events.session_start?.({}, ctx);
|
|
471
|
+
const stream = providers["anthropic"].streamSimple(
|
|
472
|
+
{ provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" },
|
|
473
|
+
ctx,
|
|
474
|
+
);
|
|
475
|
+
const emitted = [] as any[];
|
|
476
|
+
for await (const event of stream) emitted.push(event);
|
|
477
|
+
|
|
478
|
+
// Fell back cross-provider and actually served the turn on Codex.
|
|
479
|
+
assert.deepEqual(setModelCalls.map((m) => m.provider), ["openai-codex"]);
|
|
480
|
+
assert.equal(streamedModels.length, 1);
|
|
481
|
+
assert.equal(streamedModels[0].provider, "openai-codex");
|
|
482
|
+
assert.ok(emitted.some((e) => e.type === "text_delta"));
|
|
483
|
+
assert.ok(!emitted.some((e) => e.type === "error"));
|
|
484
|
+
|
|
485
|
+
// The default slot's metadata entry got the ~30-day invalid-refresh-token cooldown,
|
|
486
|
+
// through the real `withLockAsync` seam -- not a bypass.
|
|
487
|
+
const [entry] = helpers.readSubscriptionPool("anthropic");
|
|
488
|
+
assert.equal(entry.label, "anthropic");
|
|
489
|
+
assert.ok(entry.rateLimitedUntil > Date.now() + 29 * 24 * 60 * 60 * 1000);
|
|
490
|
+
assert.deepEqual(Object.keys(entry).sort(), ["label", "lastAttemptAt", "lastRateLimitedAt", "rateLimitedUntil"]);
|
|
491
|
+
|
|
492
|
+
// No UI -- no reauth happened; auth.json still holds the dead credential unchanged.
|
|
493
|
+
const authRaw = JSON.parse(readFileSync(authPath, "utf8"));
|
|
494
|
+
assert.equal(authRaw.anthropic.refresh, "dead-refresh");
|
|
495
|
+
} finally {
|
|
496
|
+
rotationModule.__setRefreshForProviderForTest(undefined);
|
|
497
|
+
rotationModule.__setStreamForProviderForTest(undefined);
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("/provider-sub add still adds an explicit additional subscription alongside the default slot", () => {
|
|
502
|
+
resetRotationState();
|
|
503
|
+
helpers.writeSubscriptionPool("anthropic", [credential("anthropic", { refresh: "refresh-first", access: "access-first" })]);
|
|
504
|
+
|
|
505
|
+
helpers.upsertSubscription("anthropic", credential("work", { refresh: "refresh-work", access: "access-work" }));
|
|
506
|
+
|
|
507
|
+
assert.deepEqual(helpers.readSubscriptionPool("anthropic").map((entry) => entry.label), ["anthropic", "work"]);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
// /provider-sub <provider> add accepts the default label as the login-backed slot.
|
|
511
|
+
// The pool stays metadata-only for that label, and the registered command handler writes
|
|
512
|
+
// the credential to auth.json.
|
|
513
|
+
|
|
514
|
+
test("/provider-sub add with the default label persists the login credential to auth.json instead of discarding it", async () => {
|
|
515
|
+
resetRotationState();
|
|
516
|
+
const authPath = join(home, ".pi", "agent", "auth.json");
|
|
517
|
+
|
|
518
|
+
const { ctx, commands, pi } = makeRotationCtx();
|
|
519
|
+
ctx.hasUI = true;
|
|
520
|
+
ctx.ui.notify = () => {};
|
|
521
|
+
ctx.ui.input = async (_message: string, placeholder: string) => placeholder; // accept the prompt's own default
|
|
522
|
+
rotationModule.__setLoginForProviderForTest(async () => ({ refresh: "refresh-default-login", access: "access-default-login", expires: Date.now() + 60_000 }));
|
|
523
|
+
|
|
524
|
+
try {
|
|
525
|
+
await providerRotationExtension(pi);
|
|
526
|
+
await commands["provider-sub"].handler("anthropic add", ctx);
|
|
527
|
+
|
|
528
|
+
// The credential must be persisted to auth.json (the single owner), NOT discarded.
|
|
529
|
+
const authRaw = JSON.parse(readFileSync(authPath, "utf8"));
|
|
530
|
+
assert.equal(authRaw.anthropic.refresh, "refresh-default-login");
|
|
531
|
+
assert.equal(authRaw.anthropic.access, "access-default-login");
|
|
532
|
+
|
|
533
|
+
// The pool gets a metadata-only default entry (no value written into it) with the
|
|
534
|
+
// cooldown cleared -- not a broken "authenticated but valueless" slot.
|
|
535
|
+
const [entry] = helpers.readSubscriptionPool("anthropic");
|
|
536
|
+
assert.equal(entry.label, "anthropic");
|
|
537
|
+
assert.deepEqual(Object.keys(entry).sort(), ["label", "lastAttemptAt", "lastRateLimitedAt", "rateLimitedUntil"]);
|
|
538
|
+
assert.equal(entry.rateLimitedUntil, 0);
|
|
539
|
+
} finally {
|
|
540
|
+
rotationModule.__setLoginForProviderForTest(undefined);
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// The retained agent command must use the same coordinated removal as the Providers panel:
|
|
545
|
+
// removing the default metadata slot also removes auth.json, so a later read cannot re-seed it.
|
|
546
|
+
test("/provider-sub rm removes the default slot and its auth.json credential together", async () => {
|
|
547
|
+
resetRotationState();
|
|
548
|
+
const authPath = join(home, ".pi", "agent", "auth.json");
|
|
549
|
+
mkdirSync(dirname(authPath), { recursive: true });
|
|
550
|
+
writeFileSync(authPath, `${JSON.stringify({ anthropic: { type: "oauth", refresh: "refresh-default", access: "access-default", expires: Date.now() + 60_000 } })}\n`, "utf8");
|
|
551
|
+
assert.equal(helpers.readSubscriptionPool("anthropic")[0]?.label, "anthropic");
|
|
552
|
+
|
|
553
|
+
const { ctx, commands, pi } = makeRotationCtx();
|
|
554
|
+
ctx.ui.notify = () => {};
|
|
555
|
+
await providerRotationExtension(pi);
|
|
556
|
+
await commands["provider-sub"].handler("anthropic rm anthropic", ctx);
|
|
557
|
+
|
|
558
|
+
assert.equal(JSON.parse(readFileSync(authPath, "utf8")).anthropic, undefined);
|
|
559
|
+
assert.equal(helpers.readSubscriptionPool("anthropic").some((entry) => entry.label === "anthropic"), false);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
// A locked auth.json write failure must not let the default-label add path clear the
|
|
563
|
+
// pool cooldown bookkeeping or pretend the add succeeded.
|
|
564
|
+
test("/provider-sub add with the default label keeps the pool cooldown when auth.json write fails", async () => {
|
|
565
|
+
resetRotationState();
|
|
566
|
+
const authPath = join(home, ".pi", "agent", "auth.json");
|
|
567
|
+
mkdirSync(dirname(authPath), { recursive: true });
|
|
568
|
+
writeFileSync(
|
|
569
|
+
authPath,
|
|
570
|
+
`${JSON.stringify({
|
|
571
|
+
anthropic: { type: "oauth", refresh: "refresh-existing", access: "access-existing", expires: Date.now() + 60_000 },
|
|
572
|
+
})}\n`,
|
|
573
|
+
"utf8",
|
|
574
|
+
);
|
|
575
|
+
helpers.readSubscriptionPool("anthropic");
|
|
576
|
+
helpers.markSubscriptionRateLimited("anthropic", "anthropic", 60_000, 100, 200);
|
|
577
|
+
const beforeCooldown = helpers.readSubscriptionPool("anthropic")[0].rateLimitedUntil;
|
|
578
|
+
|
|
579
|
+
const { ctx, commands, pi } = makeRotationCtx();
|
|
580
|
+
ctx.hasUI = true;
|
|
581
|
+
ctx.ui.notify = () => {};
|
|
582
|
+
ctx.ui.input = async (_message: string, placeholder: string) => placeholder;
|
|
583
|
+
rotationModule.__setLoginForProviderForTest(async () => ({ refresh: "refresh-default-login", access: "access-default-login", expires: Date.now() + 60_000 }));
|
|
584
|
+
|
|
585
|
+
try {
|
|
586
|
+
makeAuthFileReadOnly(authPath);
|
|
587
|
+
await providerRotationExtension(pi);
|
|
588
|
+
await commands["provider-sub"].handler("anthropic add", ctx);
|
|
589
|
+
|
|
590
|
+
const authRaw = JSON.parse(readFileSync(authPath, "utf8"));
|
|
591
|
+
assert.equal(authRaw.anthropic.refresh, "refresh-existing");
|
|
592
|
+
const [entry] = helpers.readSubscriptionPool("anthropic");
|
|
593
|
+
assert.equal(entry.label, "anthropic");
|
|
594
|
+
assert.equal(entry.rateLimitedUntil, beforeCooldown);
|
|
595
|
+
} finally {
|
|
596
|
+
makeAuthFileWritable(authPath);
|
|
597
|
+
rotationModule.__setLoginForProviderForTest(undefined);
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
// loginWithManualInputCleanup (reviews mrf5doek + mrf68z4h): the manual-code-paste
|
|
602
|
+
// dialog races pi-ai's loopback callback server. These exercise the wrapper directly
|
|
603
|
+
// (not through __setLoginForProviderForTest, which bypasses it entirely) against a
|
|
604
|
+
// fake `run` that mimics pi-ai's own "call onManualCodeInput, maybe never await it"
|
|
605
|
+
// behavior. Dismissal is now correlated at the BROKER (aborting the dialog's signal
|
|
606
|
+
// drops its pending entry AND emits an extension_ui_dismiss frame keyed to the
|
|
607
|
+
// request id), not a broad notify -- so these assert on the SIGNAL, not a notify.
|
|
608
|
+
|
|
609
|
+
test("loginWithManualInputCleanup aborts the still-open manual dialog's signal once the OAuth call settles without it (loopback-wins parity)", async () => {
|
|
610
|
+
let capturedSignal: AbortSignal | undefined;
|
|
611
|
+
const notifications: Array<{ message: string; type?: string }> = [];
|
|
612
|
+
const ctx = {
|
|
613
|
+
ui: {
|
|
614
|
+
notify: (message: string, type?: string) => notifications.push({ message, type }),
|
|
615
|
+
input: (_title: string, _placeholder: string, opts?: { signal?: AbortSignal }) => {
|
|
616
|
+
capturedSignal = opts?.signal;
|
|
617
|
+
// Never resolves on its own -- mirrors pi-ai abandoning the manual-input
|
|
618
|
+
// promise once the loopback callback server wins the race.
|
|
619
|
+
return new Promise<string | undefined>(() => {});
|
|
620
|
+
},
|
|
621
|
+
},
|
|
622
|
+
} as any;
|
|
623
|
+
|
|
624
|
+
const result = await rotationModule.loginWithManualInputCleanup(ctx, async (readCode: any) => {
|
|
625
|
+
// Start the manual-code prompt (as onManualCodeInput would) but -- like the real
|
|
626
|
+
// pi-ai race -- never await its outcome; the loopback callback "wins" instead.
|
|
627
|
+
void readCode("Open this URL...", "placeholder").catch(() => {});
|
|
628
|
+
return "loopback-code";
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
assert.equal(result, "loopback-code");
|
|
632
|
+
assert.equal(capturedSignal?.aborted, true, "the abandoned manual-input dialog's signal must be aborted once the OAuth call settles -- the broker turns that abort into a correlated extension_ui_dismiss");
|
|
633
|
+
assert.equal(notifications.length, 0, "no broad dismiss notify -- an unrelated notify must never be used as a dialog-dismissal signal");
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
test("loginWithManualInputCleanup dismisses the manual dialog at callback-win (onProgress), NOT after a delayed token exchange", async () => {
|
|
637
|
+
// Reproduces review mrf68z4h's OpenAI/pi-ai-shaped concern: pi-ai gets the code
|
|
638
|
+
// from the loopback callback, reports progress ("exchanging..."), then awaits a
|
|
639
|
+
// token exchange that has no timeout. The manual dialog must be aborted at the
|
|
640
|
+
// callback-win boundary (dismissManual, wired to onProgress), before the exchange
|
|
641
|
+
// resolves `run` -- otherwise a slow/hung exchange leaves the overlay up.
|
|
642
|
+
let order = 0;
|
|
643
|
+
let abortedAt: number | undefined;
|
|
644
|
+
let runResolvedAt: number | undefined;
|
|
645
|
+
const ctx = {
|
|
646
|
+
ui: {
|
|
647
|
+
notify: () => {},
|
|
648
|
+
input: (_title: string, _placeholder: string, opts?: { signal?: AbortSignal }) =>
|
|
649
|
+
// Mirror the broker: resolve to undefined on abort (which readCode turns
|
|
650
|
+
// into a throw, inert on the abandoned promise like pi-ai's own .catch).
|
|
651
|
+
new Promise<string | undefined>((resolve) => {
|
|
652
|
+
opts?.signal?.addEventListener("abort", () => { abortedAt = order++; resolve(undefined); }, { once: true });
|
|
653
|
+
}),
|
|
654
|
+
},
|
|
655
|
+
} as any;
|
|
656
|
+
|
|
657
|
+
const result = await rotationModule.loginWithManualInputCleanup(ctx, async (readCode: any, dismissManual: () => void) => {
|
|
658
|
+
void readCode("Open this URL...", "placeholder").catch(() => {});
|
|
659
|
+
dismissManual(); // pi-ai's onProgress fires here -- it already has the code
|
|
660
|
+
await new Promise((r) => setTimeout(r, 25)); // the (possibly slow) token exchange
|
|
661
|
+
runResolvedAt = order++;
|
|
662
|
+
return "exchanged-token";
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
assert.equal(result, "exchanged-token");
|
|
666
|
+
assert.notEqual(abortedAt, undefined, "the manual dialog's signal must have been aborted");
|
|
667
|
+
assert.notEqual(runResolvedAt, undefined);
|
|
668
|
+
assert.ok(abortedAt! < runResolvedAt!, "the manual dialog must be dismissed at callback-win (onProgress), BEFORE the delayed token exchange completes");
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
test("loginWithManualInputCleanup's readCode rejects (rather than resolving to \"\") when the manual dialog is cancelled or times out", async () => {
|
|
672
|
+
const ctx = {
|
|
673
|
+
ui: {
|
|
674
|
+
notify: () => {},
|
|
675
|
+
// ctx.ui.input resolving to undefined is exactly what a Cancel button or the
|
|
676
|
+
// dialog's own timeout produces (dialogPromise's cancelled/timeout default).
|
|
677
|
+
input: async () => undefined,
|
|
678
|
+
},
|
|
679
|
+
} as any;
|
|
680
|
+
|
|
681
|
+
// pi-ai only calls its onPrompt paste-only fallback when onManualCodeInput
|
|
682
|
+
// RESOLVES (even to a falsy value) -- never when it rejects. Asserting
|
|
683
|
+
// loginWithManualInputCleanup itself rejects (instead of resolving to "", the
|
|
684
|
+
// pre-fix behavior) proves readCode ends the OAuth attempt directly and pi-ai
|
|
685
|
+
// never gets the chance to fall through to that second, URL-less dialog.
|
|
686
|
+
await assert.rejects(
|
|
687
|
+
() => rotationModule.loginWithManualInputCleanup(ctx, (readCode) => readCode("Open this URL...", "placeholder")),
|
|
688
|
+
/OAuth login cancelled/,
|
|
689
|
+
);
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
// The default metadata slot is seeded whenever auth.json holds an OAuth credential for
|
|
693
|
+
// the provider, even when the pool already contains explicit non-default accounts.
|
|
694
|
+
test("seeds the default metadata slot even when the pool already has an explicit non-default account", () => {
|
|
695
|
+
resetRotationState();
|
|
696
|
+
helpers.writeSubscriptionPool("anthropic", [credential("work", { refresh: "refresh-work", access: "access-work" })]);
|
|
697
|
+
|
|
698
|
+
const authPath = join(home, ".pi", "agent", "auth.json");
|
|
699
|
+
mkdirSync(dirname(authPath), { recursive: true });
|
|
700
|
+
writeFileSync(
|
|
701
|
+
authPath,
|
|
702
|
+
`${JSON.stringify({
|
|
703
|
+
anthropic: { type: "oauth", refresh: "refresh-native-login", access: "access-native-login", expires: Date.now() + 60_000 },
|
|
704
|
+
})}\n`,
|
|
705
|
+
"utf8",
|
|
706
|
+
);
|
|
707
|
+
|
|
708
|
+
const pool = helpers.readSubscriptionPool("anthropic");
|
|
709
|
+
assert.deepEqual(pool.map((entry) => entry.label).sort(), ["anthropic", "work"]);
|
|
710
|
+
const defaultEntry = pool.find((entry) => entry.label === "anthropic");
|
|
711
|
+
assert.deepEqual(Object.keys(defaultEntry!).sort(), ["label", "lastAttemptAt", "lastRateLimitedAt", "rateLimitedUntil"]);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
// An api_key auth.json record does not seed the default metadata slot.
|
|
715
|
+
test("does not seed a default metadata slot for an api_key auth.json record", () => {
|
|
716
|
+
resetRotationState();
|
|
717
|
+
const authPath = join(home, ".pi", "agent", "auth.json");
|
|
718
|
+
mkdirSync(dirname(authPath), { recursive: true });
|
|
719
|
+
writeFileSync(
|
|
720
|
+
authPath,
|
|
721
|
+
`${JSON.stringify({
|
|
722
|
+
anthropic: { type: "api_key", key: "sk-not-oauth" },
|
|
723
|
+
})}\n`,
|
|
724
|
+
"utf8",
|
|
725
|
+
);
|
|
726
|
+
|
|
727
|
+
assert.deepEqual(helpers.readSubscriptionPool("anthropic"), []);
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
// Hold the real auth.json lock in a separate process and prove the seed read fails
|
|
731
|
+
// visibly instead of silently returning an empty default slot while the file is locked.
|
|
732
|
+
test("locked auth.json seeding fails visibly instead of treating an existing default as absent", async () => {
|
|
733
|
+
resetRotationState();
|
|
734
|
+
const authPath = join(home, ".pi", "agent", "auth.json");
|
|
735
|
+
mkdirSync(dirname(authPath), { recursive: true });
|
|
736
|
+
writeFileSync(
|
|
737
|
+
authPath,
|
|
738
|
+
`${JSON.stringify({
|
|
739
|
+
anthropic: { type: "oauth", refresh: "refresh-locked", access: "access-locked", expires: Date.now() + 60_000 },
|
|
740
|
+
})}
|
|
741
|
+
`,
|
|
742
|
+
"utf8",
|
|
743
|
+
);
|
|
744
|
+
|
|
745
|
+
const subscriptionStateUrl = new URL("../../lib/subscription-state.ts", import.meta.url).href;
|
|
746
|
+
const readProbe = spawn(
|
|
747
|
+
process.execPath,
|
|
748
|
+
[
|
|
749
|
+
"--import",
|
|
750
|
+
"tsx/esm",
|
|
751
|
+
"--input-type=module",
|
|
752
|
+
"-e",
|
|
753
|
+
String.raw`const { readSubscriptionPool } = await import(${JSON.stringify(subscriptionStateUrl)});
|
|
754
|
+
process.stdout.write("ready\n");
|
|
755
|
+
await new Promise((resolve) => {
|
|
756
|
+
process.stdin.setEncoding("utf8");
|
|
757
|
+
process.stdin.once("data", resolve);
|
|
758
|
+
});
|
|
759
|
+
process.stdout.write("about-to-read\n");
|
|
760
|
+
const pool = readSubscriptionPool("anthropic");
|
|
761
|
+
process.stdout.write("result:" + JSON.stringify(pool) + "\n");`,
|
|
762
|
+
],
|
|
763
|
+
{ env: { ...process.env, HOME: home }, stdio: ["pipe", "pipe", "pipe"] },
|
|
764
|
+
);
|
|
765
|
+
let sawReady = false;
|
|
766
|
+
let sawResult = false;
|
|
767
|
+
let readStderr = "";
|
|
768
|
+
readProbe.stderr!.setEncoding("utf8");
|
|
769
|
+
readProbe.stderr!.on("data", (chunk: string) => {
|
|
770
|
+
readStderr += chunk;
|
|
771
|
+
});
|
|
772
|
+
const readReady = new Promise<void>((resolve, reject) => {
|
|
773
|
+
readProbe.once("error", reject);
|
|
774
|
+
readProbe.once("close", (code) => {
|
|
775
|
+
if (!sawReady) reject(new Error(`read-probe exited with ${code} before signalling readiness: ${readStderr.trim()}`));
|
|
776
|
+
});
|
|
777
|
+
observeLines(readProbe.stdout!, (line) => {
|
|
778
|
+
if (line === "ready") {
|
|
779
|
+
sawReady = true;
|
|
780
|
+
resolve();
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
if (line.startsWith("result:")) sawResult = true;
|
|
784
|
+
});
|
|
785
|
+
});
|
|
786
|
+
const readDone = new Promise<number | null>((resolve, reject) => {
|
|
787
|
+
readProbe.once("error", reject);
|
|
788
|
+
readProbe.once("close", resolve);
|
|
789
|
+
});
|
|
790
|
+
await readReady;
|
|
791
|
+
|
|
792
|
+
const lockHolder = spawn(
|
|
793
|
+
process.execPath,
|
|
794
|
+
[
|
|
795
|
+
"--import",
|
|
796
|
+
"tsx/esm",
|
|
797
|
+
"--input-type=module",
|
|
798
|
+
"-e",
|
|
799
|
+
String.raw`import { FileAuthStorageBackend } from "@earendil-works/pi-coding-agent";
|
|
800
|
+
const backend = new FileAuthStorageBackend();
|
|
801
|
+
await backend.withLockAsync(async () => {
|
|
802
|
+
process.stdout.write("locked\n");
|
|
803
|
+
await new Promise((resolve) => {
|
|
804
|
+
process.stdin.setEncoding("utf8");
|
|
805
|
+
process.stdin.once("data", resolve);
|
|
806
|
+
});
|
|
807
|
+
return { result: undefined };
|
|
808
|
+
});`,
|
|
809
|
+
],
|
|
810
|
+
{ env: { ...process.env, HOME: home }, stdio: ["pipe", "pipe", "pipe"] },
|
|
811
|
+
);
|
|
812
|
+
let lockStderr = "";
|
|
813
|
+
lockHolder.stderr!.setEncoding("utf8");
|
|
814
|
+
lockHolder.stderr!.on("data", (chunk: string) => {
|
|
815
|
+
lockStderr += chunk;
|
|
816
|
+
});
|
|
817
|
+
let sawLocked = false;
|
|
818
|
+
const lockReady = new Promise<void>((resolve, reject) => {
|
|
819
|
+
lockHolder.once("error", reject);
|
|
820
|
+
lockHolder.once("close", (code) => {
|
|
821
|
+
if (!sawLocked) reject(new Error(`lock-holder exited with ${code} before signalling readiness: ${lockStderr.trim()}`));
|
|
822
|
+
});
|
|
823
|
+
observeLines(lockHolder.stdout!, (line) => {
|
|
824
|
+
if (line === "locked") {
|
|
825
|
+
sawLocked = true;
|
|
826
|
+
resolve();
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
});
|
|
830
|
+
const lockDone = new Promise<void>((resolve, reject) => {
|
|
831
|
+
lockHolder.once("error", reject);
|
|
832
|
+
lockHolder.once("close", (code) => {
|
|
833
|
+
if (code === 0) resolve();
|
|
834
|
+
else reject(new Error(`lock-holder exited with ${code}: ${lockStderr.trim()}`));
|
|
835
|
+
});
|
|
836
|
+
});
|
|
837
|
+
await lockReady;
|
|
838
|
+
readProbe.stdin!.write("go\n");
|
|
839
|
+
readProbe.stdin!.end();
|
|
840
|
+
const readExitCode = await readDone;
|
|
841
|
+
lockHolder.stdin!.write("release\n");
|
|
842
|
+
lockHolder.stdin!.end();
|
|
843
|
+
|
|
844
|
+
assert.equal(sawResult, false);
|
|
845
|
+
assert.notEqual(readExitCode, 0);
|
|
846
|
+
assert.match(readStderr, /ELOCKED|Lock file is already being held/);
|
|
847
|
+
await lockDone;
|
|
848
|
+
assert.deepEqual(helpers.readSubscriptionPool("anthropic"), [{ label: "anthropic", rateLimitedUntil: 0, lastAttemptAt: 0, lastRateLimitedAt: 0 }]);
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
test("resolves Anthropic to strength-matched OpenAI Codex fallback", () => {
|
|
852
|
+
resetRotationState();
|
|
853
|
+
writeLadderConfig();
|
|
854
|
+
|
|
855
|
+
assert.deepEqual(helpers.resolveFallbackTarget("anthropic", "anthropic/claude-opus-4-8:high"), {
|
|
856
|
+
providerId: "openai-codex",
|
|
857
|
+
modelId: "gpt-5.6-sol",
|
|
858
|
+
label: "openai-codex/gpt-5.6-sol:high",
|
|
859
|
+
strength: "strong",
|
|
860
|
+
thinkingLevel: "high",
|
|
861
|
+
});
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
test("resolves OpenAI Codex to strength-matched Anthropic fallback", () => {
|
|
865
|
+
writeLadderConfig();
|
|
866
|
+
|
|
867
|
+
assert.deepEqual(helpers.resolveFallbackTarget("openai-codex", "openai-codex/gpt-5.6-terra:medium"), {
|
|
868
|
+
providerId: "anthropic",
|
|
869
|
+
modelId: "claude-sonnet-4-6",
|
|
870
|
+
label: "anthropic/claude-sonnet-4-6:high",
|
|
871
|
+
strength: "medium",
|
|
872
|
+
thinkingLevel: "high",
|
|
873
|
+
});
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
// Ultra and strong share the bare model id gpt-5.6-sol; the live thinking level selects
|
|
877
|
+
// the matching ladder rung.
|
|
878
|
+
test("disambiguates colliding ladder rungs by the live thinking level", () => {
|
|
879
|
+
resetRotationState();
|
|
880
|
+
writeLadderConfig();
|
|
881
|
+
const config = helpers.readRotationConfig();
|
|
882
|
+
|
|
883
|
+
// max -> ultra rung -> Anthropic ultra (claude-fable-5)
|
|
884
|
+
assert.deepEqual(helpers.resolveFallbackTarget("openai-codex", "gpt-5.6-sol", config, "max"), {
|
|
885
|
+
providerId: "anthropic",
|
|
886
|
+
modelId: "claude-fable-5",
|
|
887
|
+
label: "anthropic/claude-fable-5:high",
|
|
888
|
+
strength: "ultra",
|
|
889
|
+
thinkingLevel: "high",
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
// high -> strong rung -> Anthropic strong (claude-opus-4-8)
|
|
893
|
+
assert.equal(helpers.resolveFallbackTarget("openai-codex", "gpt-5.6-sol", config, "high").modelId, "claude-opus-4-8");
|
|
894
|
+
|
|
895
|
+
// No thinking level falls back to the default-strength tie-break (strong -> opus).
|
|
896
|
+
assert.equal(helpers.resolveFallbackTarget("openai-codex", "gpt-5.6-sol", config).modelId, "claude-opus-4-8");
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
test("parses Retry-After seconds and HTTP dates", () => {
|
|
900
|
+
const now = Date.UTC(2026, 5, 16, 18, 30, 0);
|
|
901
|
+
assert.equal(helpers.parseRetryAfterHeader("1.5", now), 1500);
|
|
902
|
+
|
|
903
|
+
const retryAt = new Date(now + 15_000).toUTCString();
|
|
904
|
+
const parsed = helpers.parseRetryAfterHeader(retryAt, now);
|
|
905
|
+
assert.ok(parsed !== undefined && parsed >= 14_000 && parsed <= 15_000);
|
|
906
|
+
assert.equal(helpers.parseRetryAfterHeader("", now), undefined);
|
|
907
|
+
assert.equal(helpers.parseRetryAfterHeader("not-a-delay", now), undefined);
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
test("restores preferred model when its provider pool frees up", async () => {
|
|
911
|
+
resetRotationState();
|
|
912
|
+
writeLadderConfig();
|
|
913
|
+
helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
|
|
914
|
+
helpers.writeRotationConfig({ preferredModel: { providerId: "openai-codex", modelId: "gpt-5.6-sol" } });
|
|
915
|
+
|
|
916
|
+
const setModelCalls: any[] = [];
|
|
917
|
+
const ctx = {
|
|
918
|
+
model: { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" },
|
|
919
|
+
modelRegistry: {
|
|
920
|
+
find(provider: string, id: string) {
|
|
921
|
+
return { provider, id, api: provider === "openai-codex" ? "openai-codex-responses" : "anthropic-messages" };
|
|
922
|
+
},
|
|
923
|
+
},
|
|
924
|
+
} as any;
|
|
925
|
+
const pi = {
|
|
926
|
+
async setModel(model: any) {
|
|
927
|
+
setModelCalls.push(model);
|
|
928
|
+
ctx.model = model;
|
|
929
|
+
return true;
|
|
930
|
+
},
|
|
931
|
+
} as any;
|
|
932
|
+
|
|
933
|
+
const restored = await helpers.restorePreferredModelIfPossible(pi, ctx);
|
|
934
|
+
|
|
935
|
+
assert.equal(restored, true);
|
|
936
|
+
assert.deepEqual(setModelCalls[0], {
|
|
937
|
+
provider: "openai-codex",
|
|
938
|
+
id: "gpt-5.6-sol",
|
|
939
|
+
api: "openai-codex-responses",
|
|
940
|
+
});
|
|
941
|
+
assert.equal(helpers.readRotationConfig().preferredModel, undefined);
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
test("keeps newest Retry-After cooldown across overlapping attempts", () => {
|
|
945
|
+
resetRotationState();
|
|
946
|
+
helpers.writeSubscriptionPool("anthropic", [credential("personal")]);
|
|
947
|
+
|
|
948
|
+
helpers.markSubscriptionAttempt("anthropic", "personal", 100);
|
|
949
|
+
helpers.markSubscriptionAttempt("anthropic", "personal", 200);
|
|
950
|
+
helpers.markSubscriptionRateLimited("anthropic", "personal", 60_000, 100, 150);
|
|
951
|
+
helpers.markSubscriptionRateLimited("anthropic", "personal", 5_000, 200, 205);
|
|
952
|
+
helpers.markSubscriptionRateLimited("anthropic", "personal", 10_000, 100, 300);
|
|
953
|
+
|
|
954
|
+
const [entry] = helpers.readSubscriptionPool("anthropic");
|
|
955
|
+
assert.deepEqual(
|
|
956
|
+
{ lastAttemptAt: entry.lastAttemptAt, lastRateLimitedAt: entry.lastRateLimitedAt, rateLimitedUntil: entry.rateLimitedUntil },
|
|
957
|
+
{ lastAttemptAt: 200, lastRateLimitedAt: 300, rateLimitedUntil: 10300 },
|
|
958
|
+
);
|
|
959
|
+
|
|
960
|
+
helpers.markSubscriptionSuccess("anthropic", "personal", 250);
|
|
961
|
+
assert.equal(helpers.readSubscriptionPool("anthropic")[0].rateLimitedUntil, 10300);
|
|
962
|
+
helpers.markSubscriptionSuccess("anthropic", "personal", 400);
|
|
963
|
+
assert.equal(helpers.readSubscriptionPool("anthropic")[0].rateLimitedUntil, 0);
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
// Storage-hardening (design mrf6n12x): the locked read/modify/write primitive must preserve a
|
|
967
|
+
// causally newer cooldown across a stale credential upsert, while still letting an equal-timestamp
|
|
968
|
+
// reauth clear it.
|
|
969
|
+
test("a stale upsert refreshes tokens but cannot erase a newer cooldown; an equal-timestamp reauth clears it", () => {
|
|
970
|
+
resetRotationState();
|
|
971
|
+
helpers.writeSubscriptionPool("anthropic", [credential("work", { refresh: "r0", access: "a0", lastRateLimitedAt: 100 })]);
|
|
972
|
+
// What a node reads before doing a slow token refresh.
|
|
973
|
+
const snapshot = helpers.readSubscriptionPool("anthropic")[0];
|
|
974
|
+
|
|
975
|
+
// "Other node" 429s the slot with a strictly newer basis while the refresh is in flight.
|
|
976
|
+
helpers.markSubscriptionRateLimited("anthropic", "work", 60_000, 500, 500);
|
|
977
|
+
const cooled = helpers.readSubscriptionPool("anthropic")[0];
|
|
978
|
+
assert.equal(cooled.lastRateLimitedAt, 500);
|
|
979
|
+
assert.equal(cooled.rateLimitedUntil, 60_500);
|
|
980
|
+
|
|
981
|
+
// Stale refresh upsert built from the pre-429 snapshot: fresh tokens, stale cooldown basis.
|
|
982
|
+
helpers.upsertSubscription("anthropic", { ...snapshot, refresh: "r1", access: "a1", expires: snapshot.expires ?? 0 });
|
|
983
|
+
const merged = helpers.readSubscriptionPool("anthropic")[0];
|
|
984
|
+
assert.equal(merged.refresh, "r1", "token fields take the incoming value");
|
|
985
|
+
assert.equal(merged.access, "a1");
|
|
986
|
+
assert.equal(merged.rateLimitedUntil, 60_500, "the newer cooldown survives the stale upsert");
|
|
987
|
+
assert.equal(merged.lastRateLimitedAt, 500);
|
|
988
|
+
|
|
989
|
+
// Reauth clears the cooldown while preserving lastRateLimitedAt (equal timestamp -> incoming wins).
|
|
990
|
+
helpers.upsertSubscription("anthropic", { ...merged, refresh: "r2", access: "a2", rateLimitedUntil: 0, lastRateLimitedAt: merged.lastRateLimitedAt });
|
|
991
|
+
const reauthed = helpers.readSubscriptionPool("anthropic")[0];
|
|
992
|
+
assert.equal(reauthed.rateLimitedUntil, 0, "an equal-timestamp reauth clears the cooldown");
|
|
993
|
+
assert.equal(reauthed.refresh, "r2");
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
test("a stale success cannot reopen a slot cooled down by a newer 429", () => {
|
|
997
|
+
resetRotationState();
|
|
998
|
+
helpers.writeSubscriptionPool("anthropic", [credential("personal")]);
|
|
999
|
+
helpers.markSubscriptionRateLimited("anthropic", "personal", 30_000, 500, 500);
|
|
1000
|
+
// A success whose attempt STARTED before the 429 must not clear the newer cooldown.
|
|
1001
|
+
helpers.markSubscriptionSuccess("anthropic", "personal", 200);
|
|
1002
|
+
assert.equal(helpers.readSubscriptionPool("anthropic")[0].rateLimitedUntil, 30_500);
|
|
1003
|
+
// A success that started after the 429 legitimately clears it.
|
|
1004
|
+
helpers.markSubscriptionSuccess("anthropic", "personal", 600);
|
|
1005
|
+
assert.equal(helpers.readSubscriptionPool("anthropic")[0].rateLimitedUntil, 0);
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
// A mutation must genuinely go through the per-pool-file cross-process lock: while another
|
|
1009
|
+
// process holds the pool file's lock (the SAME FileAuthStorageBackend lock the backend uses),
|
|
1010
|
+
// a mutator throws after exhausting its retry budget rather than writing unlocked; once the
|
|
1011
|
+
// holder releases, the same call succeeds.
|
|
1012
|
+
test("pool mutations take the cross-process pool-file lock", async () => {
|
|
1013
|
+
resetRotationState();
|
|
1014
|
+
helpers.writeSubscriptionPool("openai-codex", [credential("work")]);
|
|
1015
|
+
const poolFile = join(home, ".pi", "agent", "openai-codex-subscriptions.json");
|
|
1016
|
+
|
|
1017
|
+
const lockHolder = spawn(
|
|
1018
|
+
process.execPath,
|
|
1019
|
+
[
|
|
1020
|
+
"--import",
|
|
1021
|
+
"tsx/esm",
|
|
1022
|
+
"--input-type=module",
|
|
1023
|
+
"-e",
|
|
1024
|
+
String.raw`import { FileAuthStorageBackend } from "@earendil-works/pi-coding-agent";
|
|
1025
|
+
const backend = new FileAuthStorageBackend(${JSON.stringify(poolFile)});
|
|
1026
|
+
await backend.withLockAsync(async () => {
|
|
1027
|
+
process.stdout.write("locked\n");
|
|
1028
|
+
await new Promise((resolve) => {
|
|
1029
|
+
process.stdin.setEncoding("utf8");
|
|
1030
|
+
process.stdin.once("data", resolve);
|
|
1031
|
+
});
|
|
1032
|
+
return { result: undefined };
|
|
1033
|
+
});`,
|
|
1034
|
+
],
|
|
1035
|
+
{ env: { ...process.env, HOME: home }, stdio: ["pipe", "pipe", "pipe"] },
|
|
1036
|
+
);
|
|
1037
|
+
let lockStderr = "";
|
|
1038
|
+
lockHolder.stderr!.setEncoding("utf8");
|
|
1039
|
+
lockHolder.stderr!.on("data", (chunk: string) => {
|
|
1040
|
+
lockStderr += chunk;
|
|
1041
|
+
});
|
|
1042
|
+
let sawLocked = false;
|
|
1043
|
+
const lockReady = new Promise<void>((resolve, reject) => {
|
|
1044
|
+
lockHolder.once("error", reject);
|
|
1045
|
+
lockHolder.once("close", (code) => {
|
|
1046
|
+
if (!sawLocked) reject(new Error(`lock-holder exited with ${code} before signalling readiness: ${lockStderr.trim()}`));
|
|
1047
|
+
});
|
|
1048
|
+
observeLines(lockHolder.stdout!, (line) => {
|
|
1049
|
+
if (line === "locked") {
|
|
1050
|
+
sawLocked = true;
|
|
1051
|
+
resolve();
|
|
1052
|
+
}
|
|
1053
|
+
});
|
|
1054
|
+
});
|
|
1055
|
+
// Teardown releases the lock and proves the child completed its awaited unlock before the
|
|
1056
|
+
// post-release mutation runs. A wedged child still gets bounded TERM/KILL cleanup.
|
|
1057
|
+
const childClosed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) =>
|
|
1058
|
+
lockHolder.once("close", (code, signal) => resolve({ code, signal })),
|
|
1059
|
+
);
|
|
1060
|
+
let readyTimer: NodeJS.Timeout | undefined;
|
|
1061
|
+
try {
|
|
1062
|
+
await Promise.race([
|
|
1063
|
+
lockReady,
|
|
1064
|
+
new Promise<never>((_, reject) => {
|
|
1065
|
+
// Boot budget, not a semantics guard: the child is a fresh `node --import tsx/esm`
|
|
1066
|
+
// process that compiles the TS module graph before it can signal. Under the full
|
|
1067
|
+
// tier's --test-concurrency=4 on a 4-vCPU CI runner that boot alone can exceed 10s,
|
|
1068
|
+
// so give it real headroom -- a genuine lock bug shows up as a wrong outcome below,
|
|
1069
|
+
// never as a slow boot.
|
|
1070
|
+
readyTimer = setTimeout(() => reject(new Error("lock-holder did not signal readiness within 60s")), 60_000);
|
|
1071
|
+
}),
|
|
1072
|
+
]);
|
|
1073
|
+
assert.throws(() => helpers.markSubscriptionAttempt("openai-codex", "work", 123), /ELOCKED|already being held/i);
|
|
1074
|
+
} finally {
|
|
1075
|
+
if (readyTimer) clearTimeout(readyTimer);
|
|
1076
|
+
try {
|
|
1077
|
+
lockHolder.stdin!.write("release\n");
|
|
1078
|
+
lockHolder.stdin!.end();
|
|
1079
|
+
} catch {
|
|
1080
|
+
/* child already gone */
|
|
1081
|
+
}
|
|
1
1082
|
// Wait for clean exit first: code 0 proves the async unlock completed. A wedged child gets
|
|
2
1083
|
// bounded SIGTERM/SIGKILL cleanup, and its test-owned lock is removed only on that fallback.
|
|
3
1084
|
const closedWithin = (ms: number) =>
|
|
@@ -241,10 +1322,14 @@ test("auth-driven default reconciliation refuses a colliding explicit identity",
|
|
|
241
1322
|
// cannot reauth into an account held by another slot.
|
|
242
1323
|
test("the uniqueness-aware reauth upsert refreshes its slot but rejects another slot's account", () => {
|
|
243
1324
|
resetRotationState();
|
|
244
|
-
helpers.writeSubscriptionPool("openai-codex", [credential("work", { accountId: "acct-old" }), credential("other", { accountId: "acct-held" })]);
|
|
1325
|
+
helpers.writeSubscriptionPool("openai-codex", [credential("work", { accountId: "acct-old", rateLimitedUntil: 900, lastAttemptAt: 100, lastRateLimitedAt: 50 }), credential("other", { accountId: "acct-held" })]);
|
|
245
1326
|
helpers.upsertSubscriptionWithUniqueAccount("openai-codex", credential("work", { accountId: "acct-new", refresh: "r-new", access: "a-new" }));
|
|
246
1327
|
let pool = helpers.readSubscriptionPool("openai-codex");
|
|
247
|
-
|
|
1328
|
+
const refreshed = pool.find((entry) => entry.label === "work");
|
|
1329
|
+
assert.equal(refreshed?.accountId, "acct-new");
|
|
1330
|
+
assert.equal(refreshed?.rateLimitedUntil, 900);
|
|
1331
|
+
assert.equal(refreshed?.lastAttemptAt, 100);
|
|
1332
|
+
assert.equal(refreshed?.lastRateLimitedAt, 50);
|
|
248
1333
|
assert.throws(
|
|
249
1334
|
() => helpers.upsertSubscriptionWithUniqueAccount("openai-codex", credential("work", { accountId: "acct-held", refresh: "r-collision", access: "a-collision" })),
|
|
250
1335
|
(err: unknown) => err instanceof helpers.DuplicateSubscriptionError && /already added/i.test((err as Error).message),
|
|
@@ -364,6 +1449,7 @@ test("cross-process explicit adds admit exactly one duplicate Codex account", as
|
|
|
364
1449
|
resetRotationState();
|
|
365
1450
|
const poolPath = join(home, ".pi", "agent", "openai-codex-subscriptions.json");
|
|
366
1451
|
const releasePath = join(home, "release-cross-process-add-race");
|
|
1452
|
+
const holderInsidePath = join(home, "holder-inside-cross-process-add-race");
|
|
367
1453
|
const subscriptionStateUrl = new URL("../../lib/subscription-state.ts", import.meta.url).href;
|
|
368
1454
|
const spawnContender = (label: string, role: "holder" | "waiter") => {
|
|
369
1455
|
const child = spawn(
|
|
@@ -373,12 +1459,22 @@ test("cross-process explicit adds admit exactly one duplicate Codex account", as
|
|
|
373
1459
|
"tsx/esm",
|
|
374
1460
|
"--input-type=module",
|
|
375
1461
|
"-e",
|
|
376
|
-
String.raw`import { existsSync, writeSync } from "node:fs";
|
|
1462
|
+
String.raw`import { existsSync, writeSync, writeFileSync } from "node:fs";
|
|
377
1463
|
import { FileAuthStorageBackend } from "@earendil-works/pi-coding-agent";
|
|
378
1464
|
const { addSubscription, DuplicateSubscriptionError } = await import(${JSON.stringify(subscriptionStateUrl)});
|
|
379
1465
|
const poolPath = ${JSON.stringify(poolPath)};
|
|
380
1466
|
const releasePath = ${JSON.stringify(releasePath)};
|
|
1467
|
+
const holderInsidePath = ${JSON.stringify(holderInsidePath)};
|
|
381
1468
|
const role = ${JSON.stringify(role)};
|
|
1469
|
+
// Yield the CPU while polling for a coordination file instead of a tight busy-spin. Two
|
|
1470
|
+
// contenders spinning at 100% CPU starve the parent's timeout observers on an
|
|
1471
|
+
// oversubscribed CI runner, which is one way this cross-process test flaked in CI while
|
|
1472
|
+
// passing locally. Atomics.wait blocks the thread for the poll interval without burning a
|
|
1473
|
+
// core -- the semantics (proceed the instant the file appears) are unchanged.
|
|
1474
|
+
const waitForFile = (path) => {
|
|
1475
|
+
const idle = new Int32Array(new SharedArrayBuffer(4));
|
|
1476
|
+
while (!existsSync(path)) Atomics.wait(idle, 0, 0, 5);
|
|
1477
|
+
};
|
|
382
1478
|
const originalWithLock = FileAuthStorageBackend.prototype.withLock;
|
|
383
1479
|
FileAuthStorageBackend.prototype.withLock = function (fn) {
|
|
384
1480
|
if (this.authPath !== poolPath) return originalWithLock.call(this, fn);
|
|
@@ -393,16 +1489,26 @@ FileAuthStorageBackend.prototype.withLock = function (fn) {
|
|
|
393
1489
|
if ((error).code !== "ELOCKED") throw error;
|
|
394
1490
|
}
|
|
395
1491
|
writeSync(1, "acquiring\n");
|
|
396
|
-
|
|
1492
|
+
waitForFile(releasePath);
|
|
397
1493
|
}
|
|
398
1494
|
return originalWithLock.call(this, (current) => {
|
|
399
1495
|
if (role === "holder") {
|
|
400
1496
|
writeSync(1, "inside\n");
|
|
401
|
-
|
|
1497
|
+
writeFileSync(holderInsidePath, "inside\n");
|
|
1498
|
+
waitForFile(releasePath);
|
|
402
1499
|
}
|
|
403
1500
|
return fn(current);
|
|
404
1501
|
});
|
|
405
1502
|
};
|
|
1503
|
+
// The holder's wait-for-release runs inside a SYNC withLock hold, which blocks its event
|
|
1504
|
+
// loop -- so the lock library's stale-refresh timer cannot fire and the lock's mtime ages.
|
|
1505
|
+
// If that sync hold spans this waiter's multi-second tsx boot on a slow CI runner, the
|
|
1506
|
+
// lock crosses the stale threshold and the waiter's probe STEALS it instead of hitting
|
|
1507
|
+
// ELOCKED (the exact full-tier CI failure). Both contenders therefore boot in parallel,
|
|
1508
|
+
// and the waiter gates HERE -- after its own boot, before touching the pool -- on the
|
|
1509
|
+
// holder being inside the lock, keeping the holder's sync hold to the sub-second
|
|
1510
|
+
// coordination tail, far under the stale window.
|
|
1511
|
+
if (role === "waiter") waitForFile(holderInsidePath);
|
|
406
1512
|
try {
|
|
407
1513
|
addSubscription("openai-codex", {
|
|
408
1514
|
label: ${JSON.stringify(label)}, refresh: "refresh-${label}", access: "access-${label}", expires: Date.now() + 60_000,
|
|
@@ -445,16 +1551,24 @@ try {
|
|
|
445
1551
|
return { child, closed, label, outcome: () => outcome, signal, stderr: () => stderr, stdout: () => stdout, stdoutEnded };
|
|
446
1552
|
};
|
|
447
1553
|
|
|
1554
|
+
// Spawn both contenders up front so their expensive tsx boots overlap and neither boot
|
|
1555
|
+
// happens while the holder is sitting inside its sync lock hold (see the child comment on
|
|
1556
|
+
// lock staleness). The waiter self-gates on holderInsidePath, so the probe ordering --
|
|
1557
|
+
// waiter attempts the lock only while the holder is inside it -- is unchanged.
|
|
448
1558
|
const holder = spawnContender("race-left", "holder");
|
|
449
|
-
const
|
|
1559
|
+
const waiter = spawnContender("race-right", "waiter");
|
|
1560
|
+
const contenders = [holder, waiter];
|
|
450
1561
|
try {
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
1562
|
+
// Boot budgets, not semantics guards: each contender is a fresh `node --import tsx/esm`
|
|
1563
|
+
// process that compiles the TS module graph before it can signal. Under the full tier's
|
|
1564
|
+
// --test-concurrency=4 on a 4-vCPU CI runner that boot alone can exceed 10s, so give the
|
|
1565
|
+
// coordination phases real headroom -- a genuine lock bug shows up as a wrong outcome in
|
|
1566
|
+
// the assertions below, never as a slow boot.
|
|
1567
|
+
await within(Promise.all([holder.signal("attempt"), holder.signal("inside")]), 60_000, "holder did not reach the locked transform within 60s");
|
|
1568
|
+
await within(Promise.all([waiter.signal("attempt"), waiter.signal("acquiring")]), 60_000, "waiter did not attempt the held pool lock within 60s");
|
|
455
1569
|
writeFileSync(releasePath, "release\n", "utf8");
|
|
456
|
-
const exitCodes = await within(Promise.all(contenders.map((contender) => contender.closed)),
|
|
457
|
-
await within(Promise.all(contenders.map((contender) => contender.stdoutEnded)),
|
|
1570
|
+
const exitCodes = await within(Promise.all(contenders.map((contender) => contender.closed)), 30_000, "cross-process contenders did not exit within 30s");
|
|
1571
|
+
await within(Promise.all(contenders.map((contender) => contender.stdoutEnded)), 15_000, "cross-process contender output did not close within 15s");
|
|
458
1572
|
assert.deepEqual(exitCodes, [0, 0], contenders.map((contender) => contender.stderr()).join("\n"));
|
|
459
1573
|
assert.deepEqual(contenders.map((contender) => contender.outcome()).sort(), ["duplicate", "success"], contenders.map((contender) => contender.stdout()).join("\n"));
|
|
460
1574
|
const pool = helpers.readSubscriptionPool("openai-codex");
|
|
@@ -463,6 +1577,9 @@ try {
|
|
|
463
1577
|
} finally {
|
|
464
1578
|
const errors: unknown[] = [];
|
|
465
1579
|
try {
|
|
1580
|
+
// Unblock a waiter still gated on the holder-inside file as well as anyone waiting on
|
|
1581
|
+
// release, so an early assertion failure cannot leave a child polling forever.
|
|
1582
|
+
writeFileSync(holderInsidePath, "inside\n", "utf8");
|
|
466
1583
|
writeFileSync(releasePath, "release\n", "utf8");
|
|
467
1584
|
} catch (error) {
|
|
468
1585
|
errors.push(error);
|
|
@@ -542,6 +1659,14 @@ test("malformed auth during nested default persistence preserves malformed bytes
|
|
|
542
1659
|
);
|
|
543
1660
|
});
|
|
544
1661
|
|
|
1662
|
+
test("renames explicit subscriptions without permitting default-slot or case-insensitive collisions", () => {
|
|
1663
|
+
resetRotationState();
|
|
1664
|
+
helpers.writeSubscriptionPool("openai-codex", [credential("openai-codex"), credential("work"), credential("personal")]);
|
|
1665
|
+
assert.deepEqual(helpers.renameSubscription("openai-codex", "work", "Lab").map((entry) => entry.label), ["openai-codex", "Lab", "personal"]);
|
|
1666
|
+
assert.throws(() => helpers.renameSubscription("openai-codex", "Lab", " PERSONAL "), helpers.DuplicateSubscriptionError);
|
|
1667
|
+
assert.throws(() => helpers.renameSubscription("openai-codex", "openai-codex", "anything"), /Default account/);
|
|
1668
|
+
});
|
|
1669
|
+
|
|
545
1670
|
test("treats numeric subscription refs as 1-based indexes", () => {
|
|
546
1671
|
resetRotationState();
|
|
547
1672
|
const pool = [credential("alpha"), credential("beta")];
|
|
@@ -1516,3 +2641,94 @@ test("treats a Codex usage-limit message on a non-429 status as a rate limit: co
|
|
|
1516
2641
|
rotationModule.__setStreamForProviderForTest(undefined);
|
|
1517
2642
|
}
|
|
1518
2643
|
});
|
|
2644
|
+
|
|
2645
|
+
// --- Provider-management removal routing + login coordination (final review findings #1, #2) ---
|
|
2646
|
+
|
|
2647
|
+
function writeDefaultAuthRecord(providerId: "anthropic" | "openai-codex", extra: Record<string, unknown> = {}): void {
|
|
2648
|
+
const authPath = join(home, ".pi", "agent", "auth.json");
|
|
2649
|
+
mkdirSync(dirname(authPath), { recursive: true });
|
|
2650
|
+
writeFileSync(
|
|
2651
|
+
authPath,
|
|
2652
|
+
`${JSON.stringify({ [providerId]: { type: "oauth", refresh: "r", access: "a", expires: Date.now() + 60_000, ...extra } }, null, 2)}\n`,
|
|
2653
|
+
{ encoding: "utf8", mode: 0o600 },
|
|
2654
|
+
);
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
function readAuthRecord(providerId: string): unknown {
|
|
2658
|
+
const raw = readFileSync(join(home, ".pi", "agent", "auth.json"), "utf8");
|
|
2659
|
+
return (JSON.parse(raw) as Record<string, unknown>)[providerId];
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
// The exact guard finding #1 tripped: removing a managed provider from its HEADER row (no
|
|
2663
|
+
// label -- the default cursor position) MUST route through the coordinated service and clear
|
|
2664
|
+
// BOTH auth.json and the rotation pool. The old code fell through to a raw AuthStorage.logout(),
|
|
2665
|
+
// which cleared only auth.json and left the explicit account configured while the panel said
|
|
2666
|
+
// "Removed" -- so the surviving pool entry below is what catches the regression.
|
|
2667
|
+
test("removeProviderRow on a managed HEADER row clears auth.json AND the whole pool (finding #1)", () => {
|
|
2668
|
+
resetRotationState();
|
|
2669
|
+
writeDefaultAuthRecord("anthropic");
|
|
2670
|
+
helpers.upsertSubscription("anthropic", credential("work"));
|
|
2671
|
+
assert.ok(helpers.readSubscriptionPool("anthropic").some((e) => e.label === "anthropic"), "default slot seeded from auth.json");
|
|
2672
|
+
assert.ok(helpers.readSubscriptionPool("anthropic").some((e) => e.label === "work"), "explicit account present");
|
|
2673
|
+
|
|
2674
|
+
const notice = providerManagement.removeProviderRow(join(home, ".pi", "agent"), {
|
|
2675
|
+
providerId: "anthropic",
|
|
2676
|
+
name: "Claude",
|
|
2677
|
+
managed: true,
|
|
2678
|
+
configured: true,
|
|
2679
|
+
// no label -> the provider header row
|
|
2680
|
+
});
|
|
2681
|
+
|
|
2682
|
+
assert.equal(notice, "Removed Claude");
|
|
2683
|
+
assert.deepEqual(helpers.readSubscriptionPool("anthropic"), [], "pool fully torn down (would still hold 'work' under the old logout() bug)");
|
|
2684
|
+
assert.equal(readAuthRecord("anthropic"), undefined, "auth.json default record cleared");
|
|
2685
|
+
});
|
|
2686
|
+
|
|
2687
|
+
// An account ROW (has a label) removes just that account and leaves the rest of the pool intact.
|
|
2688
|
+
test("removeProviderRow on a managed account row removes only that account", () => {
|
|
2689
|
+
resetRotationState();
|
|
2690
|
+
writeDefaultAuthRecord("anthropic");
|
|
2691
|
+
helpers.upsertSubscription("anthropic", credential("work"));
|
|
2692
|
+
|
|
2693
|
+
const notice = providerManagement.removeProviderRow(join(home, ".pi", "agent"), {
|
|
2694
|
+
providerId: "anthropic",
|
|
2695
|
+
name: "Claude",
|
|
2696
|
+
managed: true,
|
|
2697
|
+
label: "work",
|
|
2698
|
+
});
|
|
2699
|
+
|
|
2700
|
+
assert.equal(notice, "Removed Claude account");
|
|
2701
|
+
assert.deepEqual(
|
|
2702
|
+
helpers.readSubscriptionPool("anthropic").map((e) => e.label),
|
|
2703
|
+
["anthropic"],
|
|
2704
|
+
"only the default slot remains",
|
|
2705
|
+
);
|
|
2706
|
+
assert.ok(readAuthRecord("anthropic"), "auth.json default record untouched");
|
|
2707
|
+
});
|
|
2708
|
+
|
|
2709
|
+
// /login coordination (finding #2): the default slot commits through commitDefaultIdentity, so
|
|
2710
|
+
// auth.json gets the oauth record AND the pool default slot reflects the identity as metadata only.
|
|
2711
|
+
test("commitManagedLogin default slot writes auth.json + metadata-only pool identity", () => {
|
|
2712
|
+
resetRotationState();
|
|
2713
|
+
helpers.commitManagedLogin("anthropic", "anthropic", { refresh: "r1", access: "a1", expires: Date.now() + 60_000, accountId: "acct-1" });
|
|
2714
|
+
|
|
2715
|
+
const record = readAuthRecord("anthropic") as { type?: string; accountId?: string } | undefined;
|
|
2716
|
+
assert.equal(record?.type, "oauth");
|
|
2717
|
+
assert.equal(record?.accountId, "acct-1");
|
|
2718
|
+
|
|
2719
|
+
const slot = helpers.readSubscriptionPool("anthropic").find((e) => e.label === "anthropic");
|
|
2720
|
+
assert.ok(slot, "default slot present");
|
|
2721
|
+
assert.equal(slot?.accountId, "acct-1", "pool default slot carries the identity");
|
|
2722
|
+
assert.equal(slot?.refresh, undefined, "pool default slot stays metadata-only (no token)");
|
|
2723
|
+
});
|
|
2724
|
+
|
|
2725
|
+
// The account-uniqueness gate the raw authStorage.login() path skipped: a second slot for the
|
|
2726
|
+
// same account (same accountId) is rejected.
|
|
2727
|
+
test("commitManagedLogin enforces account uniqueness the raw login path skipped", () => {
|
|
2728
|
+
resetRotationState();
|
|
2729
|
+
helpers.commitManagedLogin("openai-codex", "openai-codex", { refresh: "r", access: "a", expires: Date.now() + 60_000, accountId: "dup" });
|
|
2730
|
+
assert.throws(
|
|
2731
|
+
() => helpers.commitManagedLogin("openai-codex", "second", { refresh: "r2", access: "a2", expires: Date.now() + 60_000, accountId: "dup" }),
|
|
2732
|
+
/already added/,
|
|
2733
|
+
);
|
|
2734
|
+
});
|