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