From 960ca3e090a1fcc913680ebe370be7549ce4b93c Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 20 Mar 2026 22:18:29 +0800 Subject: [PATCH 001/376] fix(wrapper): support codex-multi-auth version flags --- README.md | 9 +++-- docs/README.md | 8 ++--- docs/getting-started.md | 6 +++- docs/reference/commands.md | 2 ++ docs/troubleshooting.md | 2 ++ docs/upgrade.md | 3 +- scripts/codex-multi-auth.js | 40 ++++++++++++++++++----- test/codex-multi-auth-bin-wrapper.test.ts | 38 +++++++++++++++++++++ 8 files changed, 90 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index f4e5e25f..3e520e70 100644 --- a/README.md +++ b/README.md @@ -62,8 +62,11 @@ npm i -g codex-multi-auth ### Option C: Verify wiring +`codex --version` confirms the official Codex CLI is reachable. `codex-multi-auth --version` confirms the installed wrapper package version. + ```bash codex --version +codex-multi-auth --version codex auth status ``` @@ -291,9 +294,9 @@ codex auth doctor --json ## Release Notes -- Current stable: [docs/releases/v1.2.0.md](docs/releases/v1.2.0.md) -- Previous stable: [docs/releases/v1.1.10.md](docs/releases/v1.1.10.md) -- Earlier stable: [docs/releases/v0.1.9.md](docs/releases/v0.1.9.md) +- Current stable: [docs/releases/v1.1.10.md](docs/releases/v1.1.10.md) +- Previous stable: [docs/releases/v0.1.9.md](docs/releases/v0.1.9.md) +- Earlier stable: [docs/releases/v0.1.8.md](docs/releases/v0.1.8.md) - Archived prerelease: [docs/releases/v0.1.0-beta.0.md](docs/releases/v0.1.0-beta.0.md) ## License diff --git a/docs/README.md b/docs/README.md index f63480fb..926e5675 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,9 +26,9 @@ Public documentation for `codex-multi-auth`. | [troubleshooting.md](troubleshooting.md) | Recovery playbooks for install, login, switching, and stale state | | [privacy.md](privacy.md) | Data handling and local storage behavior | | [upgrade.md](upgrade.md) | Migration from legacy package and path history | -| [releases/v1.2.0.md](releases/v1.2.0.md) | Stable release notes | -| [releases/v1.1.10.md](releases/v1.1.10.md) | Previous stable release notes | -| [releases/v0.1.9.md](releases/v0.1.9.md) | Earlier stable release notes | +| [releases/v1.1.10.md](releases/v1.1.10.md) | Stable release notes | +| [releases/v0.1.9.md](releases/v0.1.9.md) | Previous stable release notes | +| [releases/v0.1.8.md](releases/v0.1.8.md) | Earlier stable release notes | | [releases/v0.1.7.md](releases/v0.1.7.md) | Archived stable release notes | | [releases/v0.1.6.md](releases/v0.1.6.md) | Archived stable release notes | | [releases/v0.1.5.md](releases/v0.1.5.md) | Archived stable release notes | @@ -45,7 +45,7 @@ Public documentation for `codex-multi-auth`. | [reference/storage-paths.md](reference/storage-paths.md) | Canonical and compatibility storage paths | | [reference/public-api.md](reference/public-api.md) | Public API stability and semver contract | | [reference/error-contracts.md](reference/error-contracts.md) | CLI, JSON, and helper error semantics | -| [releases/v1.2.0.md](releases/v1.2.0.md) | Current stable release notes | +| [releases/v1.1.10.md](releases/v1.1.10.md) | Current stable release notes | | [releases/v0.1.0-beta.0.md](releases/v0.1.0-beta.0.md) | Archived prerelease reference | | [User Guides release notes](#user-guides) | Stable, previous, and archived release notes | | [releases/legacy-pre-0.1-history.md](releases/legacy-pre-0.1-history.md) | Archived pre-0.1 changelog history | diff --git a/docs/getting-started.md b/docs/getting-started.md index ab204379..ab918be9 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -26,10 +26,14 @@ npm uninstall -g @ndycode/codex-multi-auth npm i -g codex-multi-auth ``` -Verify that the wrapper is active: +Verify both installed surfaces: + +- `codex --version` checks the official `@openai/codex` CLI that the wrapper forwards to. +- `codex-multi-auth --version` checks the installed wrapper package version. ```bash codex --version +codex-multi-auth --version codex auth status ``` diff --git a/docs/reference/commands.md b/docs/reference/commands.md index c69b3065..34b04871 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -69,6 +69,8 @@ Compatibility aliases are supported: ## Compatibility and Non-TTY Behavior - `codex` remains the primary wrapper entrypoint. It routes `codex auth ...` and the compatibility aliases to the multi-auth runtime, and forwards every other command to the official `@openai/codex` CLI. +- `codex --version` reports the official `@openai/codex` CLI version. +- `codex-multi-auth --version` and `codex-multi-auth -v` report the installed wrapper package version. - In non-TTY or host-managed sessions, including `CODEX_TUI=1`, `CODEX_DESKTOP=1`, `TERM_PROGRAM=codex`, or `ELECTRON_RUN_AS_NODE=1`, auth flows degrade to deterministic text behavior. - The non-TTY fallback keeps `codex auth login` predictable: it defaults to add-account mode, skips the extra "add another account" prompt, and auto-picks the default workspace selection when a follow-up choice is needed. - `codex auth login --manual` keeps the login flow usable in browser-restricted shells by printing the OAuth URL and accepting manual callback input instead of trying to open a browser. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index ad0a67e5..c176af7a 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -27,6 +27,7 @@ Check which `codex` executable is running: ```bash where codex codex --version +codex-multi-auth --version codex auth status codex multi auth status npm ls -g codex-multi-auth @@ -118,6 +119,7 @@ Attach these outputs when opening a bug report: - `codex auth report --json` - `codex auth doctor --json` - `codex --version` +- `codex-multi-auth --version` - `npm ls -g codex-multi-auth` - the failing command and full terminal output diff --git a/docs/upgrade.md b/docs/upgrade.md index 00883363..64dd528b 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -32,10 +32,11 @@ npm uninstall -g @ndycode/codex-multi-auth npm i -g codex-multi-auth ``` -1. Verify routing and status: +1. Verify routing and wrapper status: ```bash codex --version +codex-multi-auth --version codex auth status ``` diff --git a/scripts/codex-multi-auth.js b/scripts/codex-multi-auth.js index 3634ef30..394f9edf 100755 --- a/scripts/codex-multi-auth.js +++ b/scripts/codex-multi-auth.js @@ -1,18 +1,40 @@ #!/usr/bin/env node import { createRequire } from "node:module"; -import { runCodexMultiAuthCli } from "../dist/lib/codex-manager.js"; -try { +const versionFlags = new Set(["--version", "-v"]); + +function resolveCliVersion() { const require = createRequire(import.meta.url); - const pkg = require("../package.json"); - const version = typeof pkg?.version === "string" ? pkg.version.trim() : ""; - if (version.length > 0) { - process.env.CODEX_MULTI_AUTH_CLI_VERSION = version; + try { + const pkg = require("../package.json"); + const version = typeof pkg?.version === "string" ? pkg.version.trim() : ""; + if (version.length > 0) { + return version; + } + } catch { + // Best effort only. } -} catch { - // Best effort only. + return ""; +} + +const args = process.argv.slice(2); +const version = resolveCliVersion(); + +if (version.length > 0) { + process.env.CODEX_MULTI_AUTH_CLI_VERSION = version; } -const exitCode = await runCodexMultiAuthCli(process.argv.slice(2)); +if (args.length === 1 && versionFlags.has(args[0])) { + if (version.length > 0) { + process.stdout.write(`${version}\n`); + process.exitCode = 0; + } else { + process.stderr.write("codex-multi-auth version is unavailable.\n"); + process.exitCode = 1; + } +} else { + const { runCodexMultiAuthCli } = await import("../dist/lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(args); process.exitCode = Number.isInteger(exitCode) ? exitCode : 1; +} diff --git a/test/codex-multi-auth-bin-wrapper.test.ts b/test/codex-multi-auth-bin-wrapper.test.ts index 2a5c4562..2020f26d 100644 --- a/test/codex-multi-auth-bin-wrapper.test.ts +++ b/test/codex-multi-auth-bin-wrapper.test.ts @@ -42,6 +42,11 @@ function createWrapperFixture(): string { createdDirs.push(fixtureRoot); const scriptDir = join(fixtureRoot, "scripts"); mkdirSync(scriptDir, { recursive: true }); + writeFileSync( + join(fixtureRoot, "package.json"), + JSON.stringify({ type: "module", version: "9.8.7" }, null, "\t"), + "utf8", + ); copyFileSync( join(repoRootDir, "scripts", "codex-multi-auth.js"), join(scriptDir, "codex-multi-auth.js"), @@ -69,6 +74,39 @@ afterEach(async () => { }); describe("codex-multi-auth bin wrapper", () => { + it("prints package version for --version without loading the runtime", () => { + const fixtureRoot = createWrapperFixture(); + const result = runWrapper(fixtureRoot, ["--version"]); + + expect(result.status).toBe(0); + expect(result.stdout).toBe("9.8.7\n"); + expect(result.stderr).toBe(""); + }); + + it("prints package version for -v without loading the runtime", () => { + const fixtureRoot = createWrapperFixture(); + const result = runWrapper(fixtureRoot, ["-v"]); + + expect(result.status).toBe(0); + expect(result.stdout).toBe("9.8.7\n"); + expect(result.stderr).toBe(""); + }); + + it("prints a clear error when the wrapper version cannot be resolved", () => { + const fixtureRoot = createWrapperFixture(); + writeFileSync( + join(fixtureRoot, "package.json"), + JSON.stringify({ type: "module" }, null, "\t"), + "utf8", + ); + + const result = runWrapper(fixtureRoot, ["--version"]); + + expect(result.status).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("codex-multi-auth version is unavailable."); + }); + it("propagates integer exit codes", () => { const fixtureRoot = createWrapperFixture(); const distLibDir = join(fixtureRoot, "dist", "lib"); From 858f1fa63aa40a4f87d83df16754788617f1347b Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 20 Mar 2026 22:23:05 +0800 Subject: [PATCH 002/376] test(wrapper): cover multi-arg version passthrough --- test/codex-multi-auth-bin-wrapper.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/codex-multi-auth-bin-wrapper.test.ts b/test/codex-multi-auth-bin-wrapper.test.ts index 2020f26d..3fef8309 100644 --- a/test/codex-multi-auth-bin-wrapper.test.ts +++ b/test/codex-multi-auth-bin-wrapper.test.ts @@ -107,6 +107,28 @@ describe("codex-multi-auth bin wrapper", () => { expect(result.stderr).toContain("codex-multi-auth version is unavailable."); }); + it("passes multi-argument version flags through to the runtime", () => { + const fixtureRoot = createWrapperFixture(); + const distLibDir = join(fixtureRoot, "dist", "lib"); + mkdirSync(distLibDir, { recursive: true }); + writeFileSync( + join(distLibDir, "codex-manager.js"), + [ + "export async function runCodexMultiAuthCli(args) {", + '\tif (!Array.isArray(args) || args[0] !== "--version" || args[1] !== "extra") throw new Error("bad args");', + "\treturn 6;", + "}", + ].join("\n"), + "utf8", + ); + + const result = runWrapper(fixtureRoot, ["--version", "extra"]); + + expect(result.status).toBe(6); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe(""); + }); + it("propagates integer exit codes", () => { const fixtureRoot = createWrapperFixture(); const distLibDir = join(fixtureRoot, "dist", "lib"); From da495732b48817749ba9e0c1636b505815d8bafc Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 20 Mar 2026 22:29:05 +0800 Subject: [PATCH 003/376] style(wrapper): fix runtime exit indentation --- scripts/codex-multi-auth.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/codex-multi-auth.js b/scripts/codex-multi-auth.js index 394f9edf..9cc1f2bc 100755 --- a/scripts/codex-multi-auth.js +++ b/scripts/codex-multi-auth.js @@ -36,5 +36,5 @@ if (args.length === 1 && versionFlags.has(args[0])) { } else { const { runCodexMultiAuthCli } = await import("../dist/lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(args); -process.exitCode = Number.isInteger(exitCode) ? exitCode : 1; + process.exitCode = Number.isInteger(exitCode) ? exitCode : 1; } From 0a53738758ef379196f566b35d2844fc2bbd1eb7 Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 20 Mar 2026 23:58:34 +0800 Subject: [PATCH 004/376] docs: simplify onboarding and command guidance --- README.md | 75 ++++++++++++++++++++++---------------- docs/README.md | 28 ++++++++------ docs/getting-started.md | 53 ++++++++++++++++++--------- docs/reference/commands.md | 29 +++++++++++---- lib/codex-manager.ts | 23 +++++++++--- 5 files changed, 134 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index f4e5e25f..ea2ca52c 100644 --- a/README.md +++ b/README.md @@ -93,54 +93,65 @@ codex auth check ## Quick Start +Install and sign in: + ```bash +npm i -g @openai/codex +npm i -g codex-multi-auth codex auth login +``` + +Verify the wrapper and the new account: + +```bash codex auth status codex auth check -codex auth forecast --live ``` -Day-1 command set: +Use these next: ```bash +codex auth list codex auth switch 2 -codex auth report --live --json -codex auth fix --dry-run -codex auth doctor --fix +codex auth forecast --live ``` -If the shell should not launch a browser, use the manual callback flow: +If browser launch is blocked, use the alternate login paths in [docs/getting-started.md](docs/getting-started.md#alternate-login-paths). -```bash -codex auth login --manual -CODEX_AUTH_NO_BROWSER=1 codex auth login -``` +--- -In non-TTY/manual shells, provide the full redirect URL on stdin instead of waiting for a browser callback: +## Command Toolkit -```bash -echo "http://127.0.0.1:1455/auth/callback?code=..." | codex auth login --manual -``` +### Start here -No new npm scripts or storage migration steps are required for this login-flow update. +| Command | What it answers | +| --- | --- | +| `codex auth login` | How do I add or re-open the account menu? | +| `codex auth status` | Is the wrapper active right now? | +| `codex auth check` | Do my saved accounts look healthy? | ---- +### Daily use -## Command Toolkit +| Command | What it answers | +| --- | --- | +| `codex auth list` | Which accounts are saved and which one is active? | +| `codex auth switch ` | How do I move to a different saved account? | +| `codex auth forecast --live` | Which account looks best for the next session? | + +### Repair + +| Command | What it answers | +| --- | --- | +| `codex auth verify-flagged` | Can any previously flagged account be restored? | +| `codex auth fix --dry-run` | What safe storage or account repairs are available? | +| `codex auth doctor --fix` | Can the CLI diagnose and apply the safest fixes now? | + +### Advanced -| Command | What it does | +| Command | What it answers | | --- | --- | -| `codex auth login` | Open interactive account dashboard | -| `codex auth list` | List saved accounts and active account | -| `codex auth status` | Print short runtime/status summary | -| `codex auth switch ` | Set active account by index | -| `codex auth check` | Run quick account health checks | -| `codex auth verify-flagged` | Re-test flagged accounts and optionally restore | -| `codex auth forecast --live` | Forecast best next account with live probes | -| `codex auth report --live --json` | Generate machine-readable health report | -| `codex auth fix --dry-run` | Preview safe repairs | -| `codex auth fix --live --model gpt-5-codex` | Run repairs with live probe model | -| `codex auth doctor --fix` | Diagnose and apply safe fixes | +| `codex auth report --live --json` | How do I get the full machine-readable health report? | +| `codex auth fix --live --model gpt-5-codex` | How do I run live repair probes with a chosen model? | --- @@ -291,9 +302,9 @@ codex auth doctor --json ## Release Notes -- Current stable: [docs/releases/v1.2.0.md](docs/releases/v1.2.0.md) -- Previous stable: [docs/releases/v1.1.10.md](docs/releases/v1.1.10.md) -- Earlier stable: [docs/releases/v0.1.9.md](docs/releases/v0.1.9.md) +- Current stable: [docs/releases/v1.1.10.md](docs/releases/v1.1.10.md) +- Previous stable: [docs/releases/v0.1.9.md](docs/releases/v0.1.9.md) +- Earlier stable: [docs/releases/v0.1.8.md](docs/releases/v0.1.8.md) - Archived prerelease: [docs/releases/v0.1.0-beta.0.md](docs/releases/v0.1.0-beta.0.md) ## License diff --git a/docs/README.md b/docs/README.md index f63480fb..e29c1f05 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,28 +7,25 @@ Public documentation for `codex-multi-auth`. ## Start Here 1. [Getting Started](getting-started.md) -2. [FAQ](faq.md) -3. [Architecture](architecture.md) +2. [index.md](index.md) +3. [FAQ](faq.md) 4. [Troubleshooting](troubleshooting.md) --- -## User Guides +## Daily Use | Document | Focus | | --- | --- | | [index.md](index.md) | Daily-use landing page for common `codex auth ...` workflows | -| [getting-started.md](getting-started.md) | Install, first login, and first health check | | [faq.md](faq.md) | Short answers to common adoption questions | -| [architecture.md](architecture.md) | Public system overview of the wrapper, storage, and optional plugin runtime | | [features.md](features.md) | User-facing capability map | | [configuration.md](configuration.md) | Stable defaults, precedence, and environment overrides | -| [troubleshooting.md](troubleshooting.md) | Recovery playbooks for install, login, switching, and stale state | +| [architecture.md](architecture.md) | Public system overview of the wrapper, storage, and optional plugin runtime | | [privacy.md](privacy.md) | Data handling and local storage behavior | -| [upgrade.md](upgrade.md) | Migration from legacy package and path history | -| [releases/v1.2.0.md](releases/v1.2.0.md) | Stable release notes | -| [releases/v1.1.10.md](releases/v1.1.10.md) | Previous stable release notes | -| [releases/v0.1.9.md](releases/v0.1.9.md) | Earlier stable release notes | +| [releases/v1.1.10.md](releases/v1.1.10.md) | Stable release notes | +| [releases/v0.1.9.md](releases/v0.1.9.md) | Previous stable release notes | +| [releases/v0.1.8.md](releases/v0.1.8.md) | Earlier stable release notes | | [releases/v0.1.7.md](releases/v0.1.7.md) | Archived stable release notes | | [releases/v0.1.6.md](releases/v0.1.6.md) | Archived stable release notes | | [releases/v0.1.5.md](releases/v0.1.5.md) | Archived stable release notes | @@ -36,6 +33,15 @@ Public documentation for `codex-multi-auth`. --- +## Repair + +| Document | Focus | +| --- | --- | +| [troubleshooting.md](troubleshooting.md) | Recovery playbooks for install, login, switching, and stale state | +| [upgrade.md](upgrade.md) | Migration from legacy package and path history | + +--- + ## Reference | Document | Focus | @@ -45,7 +51,7 @@ Public documentation for `codex-multi-auth`. | [reference/storage-paths.md](reference/storage-paths.md) | Canonical and compatibility storage paths | | [reference/public-api.md](reference/public-api.md) | Public API stability and semver contract | | [reference/error-contracts.md](reference/error-contracts.md) | CLI, JSON, and helper error semantics | -| [releases/v1.2.0.md](releases/v1.2.0.md) | Current stable release notes | +| [releases/v1.1.10.md](releases/v1.1.10.md) | Current stable release notes | | [releases/v0.1.0-beta.0.md](releases/v0.1.0-beta.0.md) | Archived prerelease reference | | [User Guides release notes](#user-guides) | Stable, previous, and archived release notes | | [releases/legacy-pre-0.1-history.md](releases/legacy-pre-0.1-history.md) | Archived pre-0.1 changelog history | diff --git a/docs/getting-started.md b/docs/getting-started.md index ab204379..261e6946 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -43,29 +43,31 @@ codex auth login Expected flow: -Backup restore appears as `Restore Saved Backup` under the `Recover saved accounts` heading in the onboarding menu. This flow does not add any new CLI flags or npm scripts. -See upgrade note: [onboarding restore behavior](upgrade.md#onboarding-restore-note). - 1. If no accounts are saved yet, the terminal opens directly to the sign-in menu. -2. Choose one of the sign-in options: - - `Open Browser (Easy)` for the normal OAuth flow - - `Manual / Incognito` when you need to paste the callback yourself - - `Restore Saved Backup` under the `Recover saved accounts` heading when the current pool is empty and at least one valid named backup exists under `~/.codex/multi-auth/backups` by default, or under `%CODEX_MULTI_AUTH_DIR%\backups` if you override the storage root with `CODEX_MULTI_AUTH_DIR` -3. If you choose `Restore Saved Backup`, the next menu lets you either: - - load the newest valid backup automatically - - pick a specific backup from a newest-first list - Empty, unreadable, or non-JSON backup sidecar files are skipped, so the menu entry appears only when at least one backup parses successfully and contains at least one account. -4. If you use browser or manual sign-in, complete the official OAuth flow and return to the terminal. -5. If you load a backup, the selected backup is restored, its active account is synced back into Codex CLI auth, and the login flow continues with that restored pool. -6. Confirm the restored or newly signed-in account appears in the saved account list. +2. Choose `Open Browser (Easy)` for the normal OAuth flow. +3. Complete the official OAuth flow and return to the terminal. +4. Confirm the new account appears in the saved account list. Verify the new account: ```bash +codex auth status codex auth list codex auth check ``` +Choose the next account for your next session: + +```bash +codex auth forecast --live +``` + +## Alternate Login Paths + +Use these only when the normal browser-first flow is unavailable. + +### Manual or no-browser login + If browser launch is blocked or you want to handle the callback manually: ```bash @@ -79,7 +81,24 @@ In non-TTY/manual shells, provide the full redirect URL on stdin: echo "http://127.0.0.1:1455/auth/callback?code=..." | codex auth login --manual ``` -`codex auth login` remains browser-first by default. No new npm scripts or storage migration steps are required for this auth-flow update. +`codex auth login` remains browser-first by default. + +### Restore a saved backup during onboarding + +Backup restore appears as `Restore Saved Backup` under the `Recover saved accounts` heading in the onboarding menu. + +Use it when the current pool is empty and at least one valid named backup exists under `~/.codex/multi-auth/backups` by default, or under `%CODEX_MULTI_AUTH_DIR%\backups` if you override the storage root with `CODEX_MULTI_AUTH_DIR`. + +When you choose `Restore Saved Backup`, the next menu lets you either: + +- load the newest valid backup automatically +- pick a specific backup from a newest-first list + +Empty, unreadable, or non-JSON backup sidecar files are skipped, so the menu entry appears only when at least one backup parses successfully and contains at least one account. + +If you load a backup, the selected backup is restored, its active account is synced back into Codex CLI auth, and the login flow continues with that restored pool. + +See upgrade note: [onboarding restore behavior](upgrade.md#onboarding-restore-note). --- @@ -103,9 +122,6 @@ codex auth list codex auth switch 2 codex auth check codex auth forecast --live -codex auth report --live --json -codex auth fix --dry-run -codex auth doctor --fix ``` --- @@ -149,6 +165,7 @@ codex auth check ## Next +- [index.md](index.md) - [faq.md](faq.md) - [architecture.md](architecture.md) - [features.md](features.md) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index c69b3065..8181b6e4 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -16,31 +16,46 @@ Compatibility aliases are supported: --- -## Primary Commands +## Start Here | Command | Description | | --- | --- | | `codex auth login` | Open interactive auth dashboard | -| `codex auth list` | List saved accounts and active account | | `codex auth status` | Print short runtime/account summary | -| `codex auth switch ` | Set active account by index | | `codex auth check` | Run quick account health check | -| `codex auth features` | Print implemented feature summary | --- -## Advanced Commands +## Daily Use | Command | Description | | --- | --- | -| `codex auth verify-flagged` | Verify flagged accounts and optionally restore healthy accounts | +| `codex auth list` | List saved accounts and active account | +| `codex auth switch ` | Set active account by index | | `codex auth forecast` | Forecast best account by readiness/risk | -| `codex auth report` | Generate full health report | +| `codex auth best` | Pick and optionally sync the best account | + +--- + +## Repair + +| Command | Description | +| --- | --- | +| `codex auth verify-flagged` | Verify flagged accounts and optionally restore healthy accounts | | `codex auth fix` | Apply safe account storage fixes | | `codex auth doctor` | Run diagnostics and optional repairs | --- +## Advanced + +| Command | Description | +| --- | --- | +| `codex auth features` | Print implemented feature summary | +| `codex auth report` | Generate full health report | + +--- + ## Common Flags | Flag | Applies to | Meaning | diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index b80f1204..231075e4 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -298,23 +298,30 @@ function printUsage(): void { [ "Codex Multi-Auth CLI", "", - "Usage:", + "Start here:", " codex auth login [--manual|--no-browser]", - " codex auth list", " codex auth status", + " codex auth check", + "", + "Daily use:", + " codex auth list", " codex auth switch ", " codex auth best [--live] [--json] [--model ]", - " codex auth check", - " codex auth features", - " codex auth verify-flagged [--dry-run] [--json] [--no-restore]", " codex auth forecast [--live] [--json] [--model ]", - " codex auth report [--live] [--json] [--model ] [--out ]", + "", + "Repair:", + " codex auth verify-flagged [--dry-run] [--json] [--no-restore]", " codex auth fix [--dry-run] [--json] [--live] [--model ]", " codex auth doctor [--json] [--fix] [--dry-run]", "", + "Advanced:", + " codex auth report [--live] [--json] [--model ] [--out ]", + " codex auth features", + "", "Notes:", " - Uses ~/.codex/multi-auth/openai-codex-accounts.json", " - Syncs active account into Codex CLI auth state", + " - See docs/reference/commands.md for the full command and flag matrix", ].join("\n"), ); } @@ -4647,6 +4654,10 @@ async function runAuthLogin(args: string[]): Promise { namedBackups = []; onboardingBackupDiscoveryWarning = null; console.log(`Added account. Total: ${count}`); + console.log("Next steps:"); + console.log(" codex auth status Check that the wrapper is active."); + console.log(" codex auth check Confirm your saved accounts look healthy."); + console.log(" codex auth list Review saved accounts before switching."); if (count >= ACCOUNT_LIMITS.MAX_ACCOUNTS) { console.log(`Reached maximum account limit (${ACCOUNT_LIMITS.MAX_ACCOUNTS}).`); break; From 86b064a3614612aca1d65db3efc534cf38aa39f3 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 00:22:52 +0800 Subject: [PATCH 005/376] refactor: split auth cli help and command flows --- lib/codex-manager.ts | 857 ++--------------------------- lib/codex-manager/auth-commands.ts | 807 +++++++++++++++++++++++++++ lib/codex-manager/help.ts | 136 +++++ test/documentation.test.ts | 22 +- 4 files changed, 1000 insertions(+), 822 deletions(-) create mode 100644 lib/codex-manager/auth-commands.ts create mode 100644 lib/codex-manager/help.ts diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 231075e4..dd3f4bc1 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -9,8 +9,8 @@ import { REDIRECT_URI, } from "./auth/auth.js"; import { startLocalOAuthServer } from "./auth/server.js"; -import { copyTextToClipboard, isBrowserLaunchSuppressed, openBrowserUrl } from "./auth/browser.js"; -import { promptAddAnotherAccount, promptLoginMode, type ExistingAccountInfo } from "./cli.js"; +import { copyTextToClipboard, openBrowserUrl } from "./auth/browser.js"; +import { promptLoginMode, type ExistingAccountInfo } from "./cli.js"; import { extractAccountEmail, extractAccountId, @@ -54,14 +54,10 @@ import { import { clearAccounts, findMatchingAccountIndex, - formatStorageErrorHint, - getNamedBackups, getStoragePath, loadFlaggedAccounts, loadAccounts, - StorageError, type NamedBackupSummary, - restoreAccountsFromBackup, saveFlaggedAccounts, saveAccounts, setStoragePath, @@ -80,11 +76,16 @@ import { import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; import { ANSI } from "./ui/ansi.js"; import { UI_COPY } from "./ui/copy.js"; -import { confirm } from "./ui/confirm.js"; import { paintUiText, quotaToneFromLeftPercent } from "./ui/format.js"; import { getUiRuntimeOptions } from "./ui/runtime.js"; import { select, type MenuItem } from "./ui/select.js"; -import { applyUiThemeFromDashboardSettings, configureUnifiedSettings, resolveMenuLayoutMode } from "./codex-manager/settings-hub.js"; +import { printUsage } from "./codex-manager/help.js"; +import { + runAuthLogin as runAuthLoginCommand, + runBest as runBestCommand, + runSwitch as runSwitchCommand, +} from "./codex-manager/auth-commands.js"; +import { applyUiThemeFromDashboardSettings, resolveMenuLayoutMode } from "./codex-manager/settings-hub.js"; type TokenSuccess = Extract; type TokenSuccessWithAccount = TokenSuccess & { @@ -287,76 +288,6 @@ function isAbortError(error: unknown): boolean { return maybe.name === "AbortError" || maybe.code === "ABORT_ERR"; } -function isUserCancelledOAuth(result: TokenResult): boolean { - if (result.type !== "failed") return false; - const message = (result.message ?? "").toLowerCase(); - return message.includes("cancelled"); -} - -function printUsage(): void { - console.log( - [ - "Codex Multi-Auth CLI", - "", - "Start here:", - " codex auth login [--manual|--no-browser]", - " codex auth status", - " codex auth check", - "", - "Daily use:", - " codex auth list", - " codex auth switch ", - " codex auth best [--live] [--json] [--model ]", - " codex auth forecast [--live] [--json] [--model ]", - "", - "Repair:", - " codex auth verify-flagged [--dry-run] [--json] [--no-restore]", - " codex auth fix [--dry-run] [--json] [--live] [--model ]", - " codex auth doctor [--json] [--fix] [--dry-run]", - "", - "Advanced:", - " codex auth report [--live] [--json] [--model ] [--out ]", - " codex auth features", - "", - "Notes:", - " - Uses ~/.codex/multi-auth/openai-codex-accounts.json", - " - Syncs active account into Codex CLI auth state", - " - See docs/reference/commands.md for the full command and flag matrix", - ].join("\n"), - ); -} - -type AuthLoginOptions = { - manual: boolean; -}; - -type ParsedAuthLoginArgs = - | { ok: true; options: AuthLoginOptions } - | { ok: false; message: string }; - -function parseAuthLoginArgs(args: string[]): ParsedAuthLoginArgs { - const options: AuthLoginOptions = { - manual: false, - }; - - for (const arg of args) { - if (arg === "--manual" || arg === "--no-browser") { - options.manual = true; - continue; - } - if (arg === "--help" || arg === "-h") { - printUsage(); - return { ok: false, message: "" }; - } - return { - ok: false, - message: `Unknown login option: ${arg}`, - }; - } - - return { ok: true, options }; -} - interface ImplementedFeature { id: number; name: string; @@ -2138,13 +2069,6 @@ interface ForecastCliOptions { model: string; } -interface BestCliOptions { - live: boolean; - json: boolean; - model: string; - modelProvided: boolean; -} - interface FixCliOptions { dryRun: boolean; json: boolean; @@ -2181,24 +2105,6 @@ function printForecastUsage(): void { ); } -function printBestUsage(): void { - console.log( - [ - "Usage:", - " codex auth best [--live] [--json] [--model ]", - "", - "Options:", - " --live, -l Probe live quota headers via Codex backend before switching", - " --json, -j Print machine-readable JSON output", - " --model, -m Probe model for live mode (default: gpt-5-codex)", - "", - "Behavior:", - " - Chooses the healthiest account using forecast scoring", - " - Switches to the recommended account when it is not already active", - ].join("\n"), - ); -} - function printFixUsage(): void { console.log( [ @@ -2279,49 +2185,6 @@ function parseForecastArgs(args: string[]): ParsedArgsResult return { ok: true, options }; } -function parseBestArgs(args: string[]): ParsedArgsResult { - const options: BestCliOptions = { - live: false, - json: false, - model: "gpt-5-codex", - modelProvided: false, - }; - - for (let i = 0; i < args.length; i += 1) { - const arg = args[i]; - if (!arg) continue; - if (arg === "--live" || arg === "-l") { - options.live = true; - continue; - } - if (arg === "--json" || arg === "-j") { - options.json = true; - continue; - } - if (arg === "--model" || arg === "-m") { - const value = args[i + 1]; - if (!value) { - return { ok: false, message: "Missing value for --model" }; - } - options.model = value; - options.modelProvided = true; - i += 1; - continue; - } - if (arg.startsWith("--model=")) { - const value = arg.slice("--model=".length).trim(); - if (!value) { - return { ok: false, message: "Missing value for --model" }; - } - options.model = value; - options.modelProvided = true; - continue; - } - return { ok: false, message: `Unknown option: ${arg}` }; - } - - return { ok: true, options }; -} function parseFixArgs(args: string[]): ParsedArgsResult { const options: FixCliOptions = { @@ -4374,680 +4237,15 @@ async function handleManageAction( } async function runAuthLogin(args: string[]): Promise { - const parsedArgs = parseAuthLoginArgs(args); - if (!parsedArgs.ok) { - if (parsedArgs.message) { - console.error(parsedArgs.message); - printUsage(); - return 1; - } - return 0; - } - - const loginOptions = parsedArgs.options; - setStoragePath(null); - let pendingMenuQuotaRefresh: Promise | null = null; - let menuQuotaRefreshStatus: string | undefined; - loginFlow: - while (true) { - let existingStorage = await loadAccounts(); - if (existingStorage && existingStorage.accounts.length > 0) { - while (true) { - existingStorage = await loadAccounts(); - if (!existingStorage || existingStorage.accounts.length === 0) { - break; - } - const currentStorage = existingStorage; - const displaySettings = await loadDashboardDisplaySettings(); - applyUiThemeFromDashboardSettings(displaySettings); - const quotaCache = await loadQuotaCache(); - const shouldAutoFetchLimits = displaySettings.menuAutoFetchLimits ?? true; - const showFetchStatus = displaySettings.menuShowFetchStatus ?? true; - const quotaTtlMs = displaySettings.menuQuotaTtlMs ?? DEFAULT_MENU_QUOTA_REFRESH_TTL_MS; - if (shouldAutoFetchLimits && !pendingMenuQuotaRefresh) { - const staleCount = countMenuQuotaRefreshTargets(currentStorage, quotaCache, quotaTtlMs); - if (staleCount > 0) { - if (showFetchStatus) { - menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [0/${staleCount}]`; - } - pendingMenuQuotaRefresh = refreshQuotaCacheForMenu( - currentStorage, - quotaCache, - quotaTtlMs, - (current, total) => { - if (!showFetchStatus) return; - menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [${current}/${total}]`; - }, - ) - .then(() => undefined) - .catch(() => undefined) - .finally(() => { - menuQuotaRefreshStatus = undefined; - pendingMenuQuotaRefresh = null; - }); - } - } - const flaggedStorage = await loadFlaggedAccounts(); - - const menuResult = await promptLoginMode( - toExistingAccountInfo(currentStorage, quotaCache, displaySettings), - { - flaggedCount: flaggedStorage.accounts.length, - statusMessage: showFetchStatus ? () => menuQuotaRefreshStatus : undefined, - }, - ); - - if (menuResult.mode === "cancel") { - console.log("Cancelled."); - return 0; - } - if (menuResult.mode === "check") { - await runActionPanel("Quick Check", "Checking local session + live status", async () => { - await runHealthCheck({ forceRefresh: false, liveProbe: true }); - }, displaySettings); - continue; - } - if (menuResult.mode === "deep-check") { - await runActionPanel("Deep Check", "Refreshing and testing all accounts", async () => { - await runHealthCheck({ forceRefresh: true, liveProbe: true }); - }, displaySettings); - continue; - } - if (menuResult.mode === "forecast") { - await runActionPanel("Best Account", "Comparing accounts", async () => { - await runForecast(["--live"]); - }, displaySettings); - continue; - } - if (menuResult.mode === "fix") { - await runActionPanel("Auto-Fix", "Checking and fixing common issues", async () => { - await runFix(["--live"]); - }, displaySettings); - continue; - } - if (menuResult.mode === "settings") { - await configureUnifiedSettings(displaySettings); - continue; - } - if (menuResult.mode === "verify-flagged") { - await runActionPanel("Problem Account Check", "Checking problem accounts", async () => { - await runVerifyFlagged([]); - }, displaySettings); - continue; - } - if (menuResult.mode === "fresh" && menuResult.deleteAll) { - await runActionPanel("Reset Accounts", "Deleting all saved accounts", async () => { - await clearAccountsAndReset(); - console.log("Cleared saved accounts from active storage. Recovery snapshots remain available."); - }, displaySettings); - continue; - } - if (menuResult.mode === "manage") { - const requiresInteractiveOAuth = typeof menuResult.refreshAccountIndex === "number"; - if (requiresInteractiveOAuth) { - await handleManageAction(currentStorage, menuResult); - continue; - } - await runActionPanel("Applying Change", "Updating selected account", async () => { - await handleManageAction(currentStorage, menuResult); - }, displaySettings); - continue; - } - if (menuResult.mode === "add") { - break; - } - } - } - - const refreshedStorage = await loadAccounts(); - let existingCount = refreshedStorage?.accounts.length ?? 0; - let forceNewLogin = existingCount > 0; - let onboardingBackupDiscoveryWarning: string | null = null; - const loadNamedBackupsForOnboarding = async (): Promise => { - if (existingCount > 0) { - onboardingBackupDiscoveryWarning = null; - return []; - } - try { - onboardingBackupDiscoveryWarning = null; - return await getNamedBackups(); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - log.debug("getNamedBackups failed, skipping restore option", { - code, - error: error instanceof Error ? error.message : String(error), - }); - if (code && code !== "ENOENT") { - onboardingBackupDiscoveryWarning = - "Named backup discovery failed. Continuing with browser or manual sign-in only."; - console.warn( - onboardingBackupDiscoveryWarning, - ); - } else { - onboardingBackupDiscoveryWarning = null; - } - return []; - } - }; - let namedBackups = await loadNamedBackupsForOnboarding(); - while (true) { - const latestNamedBackup = namedBackups[0] ?? null; - const preferManualMode = loginOptions.manual || isBrowserLaunchSuppressed(); - const signInMode = preferManualMode - ? "manual" - : await promptOAuthSignInMode( - latestNamedBackup, - onboardingBackupDiscoveryWarning, - ); - if (signInMode === "cancel") { - if (existingCount > 0) { - console.log(stylePromptText(UI_COPY.oauth.cancelledBackToMenu, "muted")); - continue loginFlow; - } - console.log("Cancelled."); - return 0; - } - if (signInMode === "restore-backup") { - const latestAvailableBackup = namedBackups[0] ?? null; - if (!latestAvailableBackup) { - namedBackups = await loadNamedBackupsForOnboarding(); - continue; - } - const restoreMode = await promptBackupRestoreMode(latestAvailableBackup); - if (restoreMode === "back") { - namedBackups = await loadNamedBackupsForOnboarding(); - continue; - } - - const selectedBackup = restoreMode === "manual" - ? await promptManualBackupSelection(namedBackups) - : latestAvailableBackup; - if (!selectedBackup) { - namedBackups = await loadNamedBackupsForOnboarding(); - continue; - } - - const confirmed = await confirm( - UI_COPY.oauth.restoreBackupConfirm( - selectedBackup.fileName, - selectedBackup.accountCount, - ), - ); - if (!confirmed) { - namedBackups = await loadNamedBackupsForOnboarding(); - continue; - } - - const displaySettings = await loadDashboardDisplaySettings(); - applyUiThemeFromDashboardSettings(displaySettings); - try { - await runActionPanel( - "Load Backup", - `Loading ${selectedBackup.fileName}`, - async () => { - const restoredStorage = await restoreAccountsFromBackup( - selectedBackup.path, - { persist: false }, - ); - const targetIndex = resolveActiveIndex(restoredStorage); - const { synced } = await persistAndSyncSelectedAccount({ - storage: restoredStorage, - targetIndex, - parsed: targetIndex + 1, - switchReason: "restore", - preserveActiveIndexByFamily: true, - }); - console.log( - UI_COPY.oauth.restoreBackupLoaded( - selectedBackup.fileName, - restoredStorage.accounts.length, - ), - ); - if (!synced) { - console.warn(UI_COPY.oauth.restoreBackupSyncWarning); - } - }, - displaySettings, - ); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (error instanceof StorageError) { - console.error(formatStorageErrorHint(error, selectedBackup.path)); - } else { - console.error(`Backup restore failed: ${message}`); - } - const storageAfterRestoreAttempt = await loadAccounts().catch(() => null); - if ((storageAfterRestoreAttempt?.accounts.length ?? 0) > 0) { - continue loginFlow; - } - namedBackups = await loadNamedBackupsForOnboarding(); - continue; - } - continue loginFlow; - } - - if (signInMode !== "browser" && signInMode !== "manual") { - continue; - } - - const tokenResult = await runOAuthFlow(forceNewLogin, signInMode); - if (tokenResult.type !== "success") { - if (isUserCancelledOAuth(tokenResult)) { - if (existingCount > 0) { - console.log(stylePromptText(UI_COPY.oauth.cancelledBackToMenu, "muted")); - continue loginFlow; - } - console.log("Cancelled."); - return 0; - } - console.error(`Login failed: ${tokenResult.message ?? tokenResult.reason ?? "unknown error"}`); - return 1; - } - - const resolved = resolveAccountSelection(tokenResult); - await persistAccountPool([resolved], false); - await syncSelectionToCodex(resolved); - - const latestStorage = await loadAccounts(); - const count = latestStorage?.accounts.length ?? 1; - existingCount = count; - namedBackups = []; - onboardingBackupDiscoveryWarning = null; - console.log(`Added account. Total: ${count}`); - console.log("Next steps:"); - console.log(" codex auth status Check that the wrapper is active."); - console.log(" codex auth check Confirm your saved accounts look healthy."); - console.log(" codex auth list Review saved accounts before switching."); - if (count >= ACCOUNT_LIMITS.MAX_ACCOUNTS) { - console.log(`Reached maximum account limit (${ACCOUNT_LIMITS.MAX_ACCOUNTS}).`); - break; - } - - const addAnother = await promptAddAnotherAccount(count); - if (!addAnother) break; - forceNewLogin = true; - } - continue loginFlow; - } + return runAuthLoginCommand(args, authLoginCommandDeps); } async function runSwitch(args: string[]): Promise { - setStoragePath(null); - const indexArg = args[0]; - if (!indexArg) { - console.error("Missing index. Usage: codex auth switch "); - return 1; - } - const parsed = Number.parseInt(indexArg, 10); - if (!Number.isFinite(parsed) || parsed < 1) { - console.error(`Invalid index: ${indexArg}`); - return 1; - } - const targetIndex = parsed - 1; - - const storage = await loadAccounts(); - if (!storage || storage.accounts.length === 0) { - console.error("No accounts configured."); - return 1; - } - if (targetIndex < 0 || targetIndex >= storage.accounts.length) { - console.error(`Index out of range. Valid range: 1-${storage.accounts.length}`); - return 1; - } - - const account = storage.accounts[targetIndex]; - if (!account) { - console.error(`Account ${parsed} not found.`); - return 1; - } - - const { synced, wasDisabled } = await persistAndSyncSelectedAccount({ - storage, - targetIndex, - parsed, - switchReason: "rotation", - }); - if (!synced) { - console.warn( - `Switched account ${parsed} locally, but Codex auth sync did not complete. Multi-auth routing will still use this account.`, - ); - } - - console.log( - `Switched to account ${parsed}: ${formatAccountLabel(account, targetIndex)}${wasDisabled ? " (re-enabled)" : ""}`, - ); - return 0; -} - -async function persistAndSyncSelectedAccount({ - storage, - targetIndex, - parsed, - switchReason, - initialSyncIdToken, - preserveActiveIndexByFamily = false, -}: { - storage: NonNullable>>; - targetIndex: number; - parsed: number; - switchReason: "rotation" | "best" | "restore"; - initialSyncIdToken?: string; - preserveActiveIndexByFamily?: boolean; -}): Promise<{ synced: boolean; wasDisabled: boolean }> { - const account = storage.accounts[targetIndex]; - if (!account) { - throw new Error(`Account ${parsed} not found.`); - } - - const shouldPreserveActiveIndexByFamily = - preserveActiveIndexByFamily && - !!storage.activeIndexByFamily && - targetIndex === storage.activeIndex; - storage.activeIndex = targetIndex; - storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; - if (shouldPreserveActiveIndexByFamily) { - const maxIndex = Math.max(0, storage.accounts.length - 1); - for (const family of MODEL_FAMILIES) { - const raw = storage.activeIndexByFamily[family]; - const candidate = - typeof raw === "number" && Number.isFinite(raw) ? raw : targetIndex; - storage.activeIndexByFamily[family] = Math.max( - 0, - Math.min(candidate, maxIndex), - ); - } - } else { - storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; - for (const family of MODEL_FAMILIES) { - storage.activeIndexByFamily[family] = targetIndex; - } - } - const wasDisabled = account.enabled === false; - if (wasDisabled) { - account.enabled = true; - } - const switchNow = Date.now(); - let syncAccessToken = account.accessToken; - let syncRefreshToken = account.refreshToken; - let syncExpiresAt = account.expiresAt; - let syncIdToken = initialSyncIdToken; - - if (!hasUsableAccessToken(account, switchNow)) { - const refreshResult = await queuedRefresh(account.refreshToken); - if (refreshResult.type === "success") { - const tokenAccountId = extractAccountId(refreshResult.access); - const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)); - if (account.refreshToken !== refreshResult.refresh) { - account.refreshToken = refreshResult.refresh; - } - if (account.accessToken !== refreshResult.access) { - account.accessToken = refreshResult.access; - } - if (account.expiresAt !== refreshResult.expires) { - account.expiresAt = refreshResult.expires; - } - if (nextEmail && nextEmail !== account.email) { - account.email = nextEmail; - } - applyTokenAccountIdentity(account, tokenAccountId); - syncAccessToken = refreshResult.access; - syncRefreshToken = refreshResult.refresh; - syncExpiresAt = refreshResult.expires; - syncIdToken = refreshResult.idToken; - } else { - console.warn( - `Switch validation refresh failed for account ${parsed}: ${normalizeFailureDetail(refreshResult.message, refreshResult.reason)}.`, - ); - } - } - - account.lastUsed = switchNow; - account.lastSwitchReason = switchReason; - await saveAccounts(storage); - - const synced = await setCodexCliActiveSelection({ - accountId: account.accountId, - email: account.email, - accessToken: syncAccessToken, - refreshToken: syncRefreshToken, - expiresAt: syncExpiresAt, - ...(syncIdToken ? { idToken: syncIdToken } : {}), - }); - return { synced, wasDisabled }; + return runSwitchCommand(args, authCommandHelpers); } async function runBest(args: string[]): Promise { - if (args.includes("--help") || args.includes("-h")) { - printBestUsage(); - return 0; - } - - const parsedArgs = parseBestArgs(args); - if (!parsedArgs.ok) { - console.error(parsedArgs.message); - printBestUsage(); - return 1; - } - const options = parsedArgs.options; - if (options.modelProvided && !options.live) { - console.error("--model requires --live for codex auth best"); - printBestUsage(); - return 1; - } - - setStoragePath(null); - const storage = await loadAccounts(); - if (!storage || storage.accounts.length === 0) { - if (options.json) { - console.log(JSON.stringify({ error: "No accounts configured." }, null, 2)); - } else { - console.log("No accounts configured."); - } - return 1; - } - - const now = Date.now(); - const refreshFailures = new Map(); - const liveQuotaByIndex = new Map>>(); - const probeIdTokenByIndex = new Map(); - const probeRefreshedIndices = new Set(); - const probeErrors: string[] = []; - let changed = false; - - const printProbeNotes = (): void => { - if (probeErrors.length === 0) return; - console.log(`Live check notes (${probeErrors.length}):`); - for (const error of probeErrors) { - console.log(` - ${error}`); - } - }; - - const persistProbeChangesIfNeeded = async (): Promise => { - if (!changed) return; - await saveAccounts(storage); - changed = false; - }; - - for (let i = 0; i < storage.accounts.length; i += 1) { - const account = storage.accounts[i]; - if (!account || !options.live) continue; - if (account.enabled === false) continue; - - let probeAccessToken = account.accessToken; - let probeAccountId = account.accountId ?? extractAccountId(account.accessToken); - if (!hasUsableAccessToken(account, now)) { - const refreshResult = await queuedRefresh(account.refreshToken); - if (refreshResult.type !== "success") { - refreshFailures.set(i, { - ...refreshResult, - message: normalizeFailureDetail(refreshResult.message, refreshResult.reason), - }); - continue; - } - - const refreshedEmail = sanitizeEmail( - extractAccountEmail(refreshResult.access, refreshResult.idToken), - ); - const refreshedAccountId = extractAccountId(refreshResult.access); - - if (account.refreshToken !== refreshResult.refresh) { - account.refreshToken = refreshResult.refresh; - changed = true; - } - if (account.accessToken !== refreshResult.access) { - account.accessToken = refreshResult.access; - changed = true; - } - if (account.expiresAt !== refreshResult.expires) { - account.expiresAt = refreshResult.expires; - changed = true; - } - if (refreshedEmail && refreshedEmail !== account.email) { - account.email = refreshedEmail; - changed = true; - } - if (refreshedAccountId && refreshedAccountId !== account.accountId) { - account.accountId = refreshedAccountId; - account.accountIdSource = "token"; - changed = true; - } - if (refreshResult.idToken) { - probeIdTokenByIndex.set(i, refreshResult.idToken); - } - probeRefreshedIndices.add(i); - - probeAccessToken = account.accessToken; - probeAccountId = account.accountId ?? refreshedAccountId; - } - - if (!probeAccessToken || !probeAccountId) { - probeErrors.push(`${formatAccountLabel(account, i)}: missing accountId for live probe`); - continue; - } - - try { - const liveQuota = await fetchCodexQuotaSnapshot({ - accountId: probeAccountId, - accessToken: probeAccessToken, - model: options.model, - }); - liveQuotaByIndex.set(i, liveQuota); - } catch (error) { - const message = normalizeFailureDetail( - error instanceof Error ? error.message : String(error), - undefined, - ); - probeErrors.push(`${formatAccountLabel(account, i)}: ${message}`); - } - } - - const forecastInputs = storage.accounts.map((account, index) => ({ - index, - account, - isCurrent: index === resolveActiveIndex(storage, "codex"), - now, - refreshFailure: refreshFailures.get(index), - liveQuota: liveQuotaByIndex.get(index), - })); - - const forecastResults = evaluateForecastAccounts(forecastInputs); - const recommendation = recommendForecastAccount(forecastResults); - - if (recommendation.recommendedIndex === null) { - await persistProbeChangesIfNeeded(); - if (options.json) { - console.log(JSON.stringify({ - error: recommendation.reason, - ...(probeErrors.length > 0 ? { probeErrors } : {}), - }, null, 2)); - } else { - console.log(`No best account available: ${recommendation.reason}`); - printProbeNotes(); - } - return 1; - } - - const bestIndex = recommendation.recommendedIndex; - const bestAccount = storage.accounts[bestIndex]; - if (!bestAccount) { - await persistProbeChangesIfNeeded(); - if (options.json) { - console.log(JSON.stringify({ error: "Best account not found." }, null, 2)); - } else { - console.log("Best account not found."); - } - return 1; - } - - // Check if already on best account - const currentIndex = resolveActiveIndex(storage, "codex"); - if (currentIndex === bestIndex) { - const shouldSyncCurrentBest = - probeRefreshedIndices.has(bestIndex) || probeIdTokenByIndex.has(bestIndex); - let alreadyBestSynced: boolean | undefined; - if (changed) { - bestAccount.lastUsed = now; - await persistProbeChangesIfNeeded(); - } - if (shouldSyncCurrentBest) { - alreadyBestSynced = await setCodexCliActiveSelection({ - accountId: bestAccount.accountId, - email: bestAccount.email, - accessToken: bestAccount.accessToken, - refreshToken: bestAccount.refreshToken, - expiresAt: bestAccount.expiresAt, - ...(probeIdTokenByIndex.has(bestIndex) - ? { idToken: probeIdTokenByIndex.get(bestIndex) } - : {}), - }); - if (!alreadyBestSynced && !options.json) { - console.warn("Codex auth sync did not complete. Multi-auth routing will still use this account."); - } - } - if (options.json) { - console.log(JSON.stringify({ - message: `Already on best account: ${formatAccountLabel(bestAccount, bestIndex)}`, - accountIndex: bestIndex + 1, - reason: recommendation.reason, - ...(alreadyBestSynced !== undefined ? { synced: alreadyBestSynced } : {}), - ...(probeErrors.length > 0 ? { probeErrors } : {}), - }, null, 2)); - } else { - console.log(`Already on best account ${bestIndex + 1}: ${formatAccountLabel(bestAccount, bestIndex)}`); - console.log(`Reason: ${recommendation.reason}`); - printProbeNotes(); - } - return 0; - } - - const targetIndex = bestIndex; - const parsed = targetIndex + 1; - const { synced, wasDisabled } = await persistAndSyncSelectedAccount({ - storage, - targetIndex, - parsed, - switchReason: "best", - initialSyncIdToken: probeIdTokenByIndex.get(bestIndex), - }); - - if (options.json) { - console.log(JSON.stringify({ - message: `Switched to best account: ${formatAccountLabel(bestAccount, targetIndex)}`, - accountIndex: parsed, - reason: recommendation.reason, - synced, - wasDisabled, - ...(probeErrors.length > 0 ? { probeErrors } : {}), - }, null, 2)); - } else { - console.log(`Switched to best account ${parsed}: ${formatAccountLabel(bestAccount, targetIndex)}${wasDisabled ? " (re-enabled)" : ""}`); - console.log(`Reason: ${recommendation.reason}`); - printProbeNotes(); - if (!synced) { - console.warn("Codex auth sync did not complete. Multi-auth routing will still use this account."); - } - } - return 0; + return runBestCommand(args, authCommandHelpers); } export async function autoSyncActiveAccountToCodex(): Promise { @@ -5119,6 +4317,37 @@ export async function autoSyncActiveAccountToCodex(): Promise { }); } +const authCommandHelpers = { + resolveActiveIndex, + hasUsableAccessToken, + applyTokenAccountIdentity, + normalizeFailureDetail, +}; + +const authLoginCommandDeps = { + ...authCommandHelpers, + stylePromptText, + runActionPanel, + toExistingAccountInfo, + countMenuQuotaRefreshTargets, + defaultMenuQuotaRefreshTtlMs: DEFAULT_MENU_QUOTA_REFRESH_TTL_MS, + refreshQuotaCacheForMenu, + clearAccountsAndReset, + handleManageAction, + promptOAuthSignInMode, + promptBackupRestoreMode, + promptManualBackupSelection, + runOAuthFlow, + resolveAccountSelection, + persistAccountPool, + syncSelectionToCodex, + runHealthCheck, + runForecast, + runFix, + runVerifyFlagged, + log, +}; + export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { const startupDisplaySettings = await loadDashboardDisplaySettings(); applyUiThemeFromDashboardSettings(startupDisplaySettings); diff --git a/lib/codex-manager/auth-commands.ts b/lib/codex-manager/auth-commands.ts new file mode 100644 index 00000000..9d3f7c80 --- /dev/null +++ b/lib/codex-manager/auth-commands.ts @@ -0,0 +1,807 @@ +import { isBrowserLaunchSuppressed } from "../auth/browser.js"; +import { + extractAccountEmail, + extractAccountId, + formatAccountLabel, + sanitizeEmail, +} from "../accounts.js"; +import { promptAddAnotherAccount, promptLoginMode, type ExistingAccountInfo } from "../cli.js"; +import { ACCOUNT_LIMITS } from "../constants.js"; +import { + loadDashboardDisplaySettings, + type DashboardDisplaySettings, +} from "../dashboard-settings.js"; +import { + evaluateForecastAccounts, + recommendForecastAccount, +} from "../forecast.js"; +import { loadQuotaCache, type QuotaCacheData } from "../quota-cache.js"; +import { fetchCodexQuotaSnapshot } from "../quota-probe.js"; +import { queuedRefresh } from "../refresh-queue.js"; +import { + getNamedBackups, + formatStorageErrorHint, + loadAccounts, + loadFlaggedAccounts, + restoreAccountsFromBackup, + saveAccounts, + setStoragePath, + StorageError, + type AccountMetadataV3, + type AccountStorageV3, + type NamedBackupSummary, +} from "../storage.js"; +import type { AccountIdSource, TokenFailure, TokenResult } from "../types.js"; +import { setCodexCliActiveSelection } from "../codex-cli/writer.js"; +import type { ModelFamily } from "../prompts/codex.js"; +import { UI_COPY } from "../ui/copy.js"; +import { confirm } from "../ui/confirm.js"; +import { + applyUiThemeFromDashboardSettings, + configureUnifiedSettings, +} from "./settings-hub.js"; +import { + parseAuthLoginArgs, + parseBestArgs, + printBestUsage, + printUsage, +} from "./help.js"; + +type PromptTone = "accent" | "success" | "warning" | "danger" | "muted"; +type TokenSuccess = Extract; +type TokenSuccessWithAccount = TokenSuccess & { + accountIdOverride?: string; + accountIdSource?: AccountIdSource; + accountLabel?: string; +}; +type OAuthSignInMode = "browser" | "manual" | "restore-backup" | "cancel"; +type BackupRestoreMode = "latest" | "manual" | "back"; +type LoginMenuResult = Awaited>; +type HealthCheckOptions = { forceRefresh?: boolean; liveProbe?: boolean }; + +export interface AuthCommandHelpers { + resolveActiveIndex: ( + storage: AccountStorageV3, + family?: ModelFamily, + ) => number; + hasUsableAccessToken: (account: AccountMetadataV3, now: number) => boolean; + applyTokenAccountIdentity: ( + account: { accountId?: string; accountIdSource?: AccountIdSource }, + tokenAccountId: string | undefined, + ) => boolean; + normalizeFailureDetail: ( + message: string | undefined, + reason: string | undefined, + ) => string; +} + +export interface AuthLoginCommandDeps extends AuthCommandHelpers { + stylePromptText: (text: string, tone: PromptTone) => string; + runActionPanel: ( + title: string, + stage: string, + action: () => Promise | void, + settings?: DashboardDisplaySettings, + ) => Promise; + toExistingAccountInfo: ( + storage: AccountStorageV3, + cache: QuotaCacheData, + settings: DashboardDisplaySettings, + ) => ExistingAccountInfo[]; + countMenuQuotaRefreshTargets: ( + storage: AccountStorageV3, + cache: QuotaCacheData, + maxAgeMs: number, + ) => number; + defaultMenuQuotaRefreshTtlMs: number; + refreshQuotaCacheForMenu: ( + storage: AccountStorageV3, + cache: QuotaCacheData, + maxAgeMs: number, + onProgress?: (current: number, total: number) => void, + ) => Promise; + clearAccountsAndReset: () => Promise; + handleManageAction: ( + storage: AccountStorageV3, + menuResult: LoginMenuResult, + ) => Promise; + promptOAuthSignInMode: ( + backupOption: NamedBackupSummary | null, + backupDiscoveryWarning?: string | null, + ) => Promise; + promptBackupRestoreMode: ( + latestBackup: NamedBackupSummary, + ) => Promise; + promptManualBackupSelection: ( + namedBackups: NamedBackupSummary[], + ) => Promise; + runOAuthFlow: ( + forceNewLogin: boolean, + signInMode: Extract, + ) => Promise; + resolveAccountSelection: (tokens: TokenSuccess) => TokenSuccessWithAccount; + persistAccountPool: ( + tokens: TokenSuccessWithAccount[], + preserveActiveIndexByFamily: boolean, + ) => Promise; + syncSelectionToCodex: (tokens: TokenSuccessWithAccount) => Promise; + runHealthCheck: (options: HealthCheckOptions) => Promise; + runForecast: (args: string[]) => Promise; + runFix: (args: string[]) => Promise; + runVerifyFlagged: (args: string[]) => Promise; + log: { + debug: (message: string, meta?: unknown) => void; + }; +} + +export async function persistAndSyncSelectedAccount({ + storage, + targetIndex, + parsed, + switchReason, + initialSyncIdToken, + preserveActiveIndexByFamily = false, + helpers, +}: { + storage: AccountStorageV3; + targetIndex: number; + parsed: number; + switchReason: "rotation" | "best" | "restore"; + initialSyncIdToken?: string; + preserveActiveIndexByFamily?: boolean; + helpers: AuthCommandHelpers; +}): Promise<{ synced: boolean; wasDisabled: boolean }> { + const account = storage.accounts[targetIndex]; + if (!account) { + throw new Error(`Account ${parsed} not found.`); + } + + const wasDisabled = account.enabled === false; + if (wasDisabled) { + account.enabled = true; + } + + storage.activeIndex = targetIndex; + if (!storage.activeIndexByFamily || !preserveActiveIndexByFamily) { + storage.activeIndexByFamily = {}; + } + storage.activeIndexByFamily.codex = targetIndex; + + const switchNow = Date.now(); + let syncAccessToken = account.accessToken; + let syncRefreshToken = account.refreshToken; + let syncExpiresAt = account.expiresAt; + let syncIdToken = initialSyncIdToken; + + if (!helpers.hasUsableAccessToken(account, switchNow)) { + const refreshResult = await queuedRefresh(account.refreshToken); + if (refreshResult.type === "success") { + const refreshedEmail = sanitizeEmail( + extractAccountEmail(refreshResult.access, refreshResult.idToken), + ); + const tokenAccountId = extractAccountId(refreshResult.access); + + account.refreshToken = refreshResult.refresh; + account.accessToken = refreshResult.access; + account.expiresAt = refreshResult.expires; + if (refreshedEmail) account.email = refreshedEmail; + helpers.applyTokenAccountIdentity(account, tokenAccountId); + syncAccessToken = refreshResult.access; + syncRefreshToken = refreshResult.refresh; + syncExpiresAt = refreshResult.expires; + syncIdToken = refreshResult.idToken; + } else { + console.warn( + `Switch validation refresh failed for account ${parsed}: ${helpers.normalizeFailureDetail(refreshResult.message, refreshResult.reason)}.`, + ); + } + } + + account.lastUsed = switchNow; + account.lastSwitchReason = switchReason; + await saveAccounts(storage); + + const synced = await setCodexCliActiveSelection({ + accountId: account.accountId, + email: account.email, + accessToken: syncAccessToken, + refreshToken: syncRefreshToken, + expiresAt: syncExpiresAt, + ...(syncIdToken ? { idToken: syncIdToken } : {}), + }); + return { synced, wasDisabled }; +} + +export async function runSwitch( + args: string[], + helpers: AuthCommandHelpers, +): Promise { + setStoragePath(null); + const indexArg = args[0]; + if (!indexArg) { + console.error("Missing index. Usage: codex auth switch "); + return 1; + } + const parsed = Number.parseInt(indexArg, 10); + if (!Number.isFinite(parsed) || parsed < 1) { + console.error(`Invalid index: ${indexArg}`); + return 1; + } + const targetIndex = parsed - 1; + + const storage = await loadAccounts(); + if (!storage || storage.accounts.length === 0) { + console.error("No accounts configured."); + return 1; + } + if (targetIndex < 0 || targetIndex >= storage.accounts.length) { + console.error(`Index out of range. Valid range: 1-${storage.accounts.length}`); + return 1; + } + + const account = storage.accounts[targetIndex]; + if (!account) { + console.error(`Account ${parsed} not found.`); + return 1; + } + + const { synced, wasDisabled } = await persistAndSyncSelectedAccount({ + storage, + targetIndex, + parsed, + switchReason: "rotation", + helpers, + }); + if (!synced) { + console.warn( + `Switched account ${parsed} locally, but Codex auth sync did not complete. Multi-auth routing will still use this account.`, + ); + } + + console.log( + `Switched to account ${parsed}: ${formatAccountLabel(account, targetIndex)}${wasDisabled ? " (re-enabled)" : ""}`, + ); + return 0; +} + +export async function runBest( + args: string[], + helpers: AuthCommandHelpers, +): Promise { + if (args.includes("--help") || args.includes("-h")) { + printBestUsage(); + return 0; + } + + const parsedArgs = parseBestArgs(args); + if (!parsedArgs.ok) { + console.error(parsedArgs.message); + printBestUsage(); + return 1; + } + const options = parsedArgs.options; + if (options.modelProvided && !options.live) { + console.error("--model requires --live for codex auth best"); + printBestUsage(); + return 1; + } + + setStoragePath(null); + const storage = await loadAccounts(); + if (!storage || storage.accounts.length === 0) { + if (options.json) { + console.log(JSON.stringify({ error: "No accounts configured." }, null, 2)); + } else { + console.log("No accounts configured."); + } + return 1; + } + + const now = Date.now(); + const refreshFailures = new Map(); + const liveQuotaByIndex = new Map>>(); + const probeIdTokenByIndex = new Map(); + const probeRefreshedIndices = new Set(); + const probeErrors: string[] = []; + let changed = false; + + const printProbeNotes = (): void => { + if (probeErrors.length === 0) return; + console.log(`Live check notes (${probeErrors.length}):`); + for (const error of probeErrors) { + console.log(` - ${error}`); + } + }; + + const persistProbeChangesIfNeeded = async (): Promise => { + if (!changed) return; + await saveAccounts(storage); + changed = false; + }; + + for (let i = 0; i < storage.accounts.length; i += 1) { + const account = storage.accounts[i]; + if (!account || !options.live) continue; + if (account.enabled === false) continue; + + let probeAccessToken = account.accessToken; + let probeAccountId = account.accountId ?? extractAccountId(account.accessToken); + if (!helpers.hasUsableAccessToken(account, now)) { + const refreshResult = await queuedRefresh(account.refreshToken); + if (refreshResult.type !== "success") { + refreshFailures.set(i, { + ...refreshResult, + message: helpers.normalizeFailureDetail(refreshResult.message, refreshResult.reason), + }); + continue; + } + + const refreshedEmail = sanitizeEmail( + extractAccountEmail(refreshResult.access, refreshResult.idToken), + ); + const refreshedAccountId = extractAccountId(refreshResult.access); + + if (account.refreshToken !== refreshResult.refresh) { + account.refreshToken = refreshResult.refresh; + changed = true; + } + if (account.accessToken !== refreshResult.access) { + account.accessToken = refreshResult.access; + changed = true; + } + if (account.expiresAt !== refreshResult.expires) { + account.expiresAt = refreshResult.expires; + changed = true; + } + if (refreshedEmail && refreshedEmail !== account.email) { + account.email = refreshedEmail; + changed = true; + } + if (refreshedAccountId && refreshedAccountId !== account.accountId) { + account.accountId = refreshedAccountId; + account.accountIdSource = "token"; + changed = true; + } + if (refreshResult.idToken) { + probeIdTokenByIndex.set(i, refreshResult.idToken); + } + probeRefreshedIndices.add(i); + + probeAccessToken = account.accessToken; + probeAccountId = account.accountId ?? refreshedAccountId; + } + + if (!probeAccessToken || !probeAccountId) { + probeErrors.push(`${formatAccountLabel(account, i)}: missing accountId for live probe`); + continue; + } + + try { + const liveQuota = await fetchCodexQuotaSnapshot({ + accountId: probeAccountId, + accessToken: probeAccessToken, + model: options.model, + }); + liveQuotaByIndex.set(i, liveQuota); + } catch (error) { + const message = helpers.normalizeFailureDetail( + error instanceof Error ? error.message : String(error), + undefined, + ); + probeErrors.push(`${formatAccountLabel(account, i)}: ${message}`); + } + } + + const forecastInputs = storage.accounts.map((account, index) => ({ + index, + account, + isCurrent: index === helpers.resolveActiveIndex(storage, "codex"), + now, + refreshFailure: refreshFailures.get(index), + liveQuota: liveQuotaByIndex.get(index), + })); + + const forecastResults = evaluateForecastAccounts(forecastInputs); + const recommendation = recommendForecastAccount(forecastResults); + + if (recommendation.recommendedIndex === null) { + await persistProbeChangesIfNeeded(); + if (options.json) { + console.log(JSON.stringify({ + error: recommendation.reason, + ...(probeErrors.length > 0 ? { probeErrors } : {}), + }, null, 2)); + } else { + console.log(`No best account available: ${recommendation.reason}`); + printProbeNotes(); + } + return 1; + } + + const bestIndex = recommendation.recommendedIndex; + const bestAccount = storage.accounts[bestIndex]; + if (!bestAccount) { + await persistProbeChangesIfNeeded(); + if (options.json) { + console.log(JSON.stringify({ error: "Best account not found." }, null, 2)); + } else { + console.log("Best account not found."); + } + return 1; + } + + const currentIndex = helpers.resolveActiveIndex(storage, "codex"); + if (currentIndex === bestIndex) { + const shouldSyncCurrentBest = + probeRefreshedIndices.has(bestIndex) || probeIdTokenByIndex.has(bestIndex); + let alreadyBestSynced: boolean | undefined; + if (changed) { + bestAccount.lastUsed = now; + await persistProbeChangesIfNeeded(); + } + if (shouldSyncCurrentBest) { + alreadyBestSynced = await setCodexCliActiveSelection({ + accountId: bestAccount.accountId, + email: bestAccount.email, + accessToken: bestAccount.accessToken, + refreshToken: bestAccount.refreshToken, + expiresAt: bestAccount.expiresAt, + ...(probeIdTokenByIndex.has(bestIndex) + ? { idToken: probeIdTokenByIndex.get(bestIndex) } + : {}), + }); + if (!alreadyBestSynced && !options.json) { + console.warn("Codex auth sync did not complete. Multi-auth routing will still use this account."); + } + } + if (options.json) { + console.log(JSON.stringify({ + message: `Already on best account: ${formatAccountLabel(bestAccount, bestIndex)}`, + accountIndex: bestIndex + 1, + reason: recommendation.reason, + ...(alreadyBestSynced !== undefined ? { synced: alreadyBestSynced } : {}), + ...(probeErrors.length > 0 ? { probeErrors } : {}), + }, null, 2)); + } else { + console.log(`Already on best account ${bestIndex + 1}: ${formatAccountLabel(bestAccount, bestIndex)}`); + console.log(`Reason: ${recommendation.reason}`); + printProbeNotes(); + } + return 0; + } + + const parsed = bestIndex + 1; + const { synced, wasDisabled } = await persistAndSyncSelectedAccount({ + storage, + targetIndex: bestIndex, + parsed, + switchReason: "best", + initialSyncIdToken: probeIdTokenByIndex.get(bestIndex), + helpers, + }); + + if (options.json) { + console.log(JSON.stringify({ + message: `Switched to best account: ${formatAccountLabel(bestAccount, bestIndex)}`, + accountIndex: parsed, + reason: recommendation.reason, + synced, + wasDisabled, + ...(probeErrors.length > 0 ? { probeErrors } : {}), + }, null, 2)); + } else { + console.log(`Switched to best account ${parsed}: ${formatAccountLabel(bestAccount, bestIndex)}${wasDisabled ? " (re-enabled)" : ""}`); + console.log(`Reason: ${recommendation.reason}`); + printProbeNotes(); + if (!synced) { + console.warn("Codex auth sync did not complete. Multi-auth routing will still use this account."); + } + } + return 0; +} + +export async function runAuthLogin( + args: string[], + deps: AuthLoginCommandDeps, +): Promise { + const parsedArgs = parseAuthLoginArgs(args); + if (!parsedArgs.ok) { + if (parsedArgs.message) { + console.error(parsedArgs.message); + printUsage(); + return 1; + } + return 0; + } + + const loginOptions = parsedArgs.options; + setStoragePath(null); + let pendingMenuQuotaRefresh: Promise | null = null; + let menuQuotaRefreshStatus: string | undefined; + loginFlow: + while (true) { + let existingStorage = await loadAccounts(); + if (existingStorage && existingStorage.accounts.length > 0) { + while (true) { + existingStorage = await loadAccounts(); + if (!existingStorage || existingStorage.accounts.length === 0) { + break; + } + const currentStorage = existingStorage; + const displaySettings = await loadDashboardDisplaySettings(); + applyUiThemeFromDashboardSettings(displaySettings); + const quotaCache = await loadQuotaCache(); + const shouldAutoFetchLimits = displaySettings.menuAutoFetchLimits ?? true; + const showFetchStatus = displaySettings.menuShowFetchStatus ?? true; + const quotaTtlMs = + displaySettings.menuQuotaTtlMs ?? deps.defaultMenuQuotaRefreshTtlMs; + if (shouldAutoFetchLimits && !pendingMenuQuotaRefresh) { + const staleCount = deps.countMenuQuotaRefreshTargets( + currentStorage, + quotaCache, + quotaTtlMs, + ); + if (staleCount > 0) { + if (showFetchStatus) { + menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [0/${staleCount}]`; + } + pendingMenuQuotaRefresh = deps.refreshQuotaCacheForMenu( + currentStorage, + quotaCache, + quotaTtlMs, + (current, total) => { + if (!showFetchStatus) return; + menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [${current}/${total}]`; + }, + ) + .then(() => undefined) + .catch(() => undefined) + .finally(() => { + menuQuotaRefreshStatus = undefined; + pendingMenuQuotaRefresh = null; + }); + } + } + const flaggedStorage = await loadFlaggedAccounts(); + + const menuResult = await promptLoginMode( + deps.toExistingAccountInfo(currentStorage, quotaCache, displaySettings), + { + flaggedCount: flaggedStorage.accounts.length, + statusMessage: showFetchStatus ? () => menuQuotaRefreshStatus : undefined, + }, + ); + + if (menuResult.mode === "cancel") { + console.log("Cancelled."); + return 0; + } + if (menuResult.mode === "check") { + await deps.runActionPanel("Quick Check", "Checking local session + live status", async () => { + await deps.runHealthCheck({ forceRefresh: false, liveProbe: true }); + }, displaySettings); + continue; + } + if (menuResult.mode === "deep-check") { + await deps.runActionPanel("Deep Check", "Refreshing and testing all accounts", async () => { + await deps.runHealthCheck({ forceRefresh: true, liveProbe: true }); + }, displaySettings); + continue; + } + if (menuResult.mode === "forecast") { + await deps.runActionPanel("Best Account", "Comparing accounts", async () => { + await deps.runForecast(["--live"]); + }, displaySettings); + continue; + } + if (menuResult.mode === "fix") { + await deps.runActionPanel("Auto-Fix", "Checking and fixing common issues", async () => { + await deps.runFix(["--live"]); + }, displaySettings); + continue; + } + if (menuResult.mode === "settings") { + await configureUnifiedSettings(displaySettings); + continue; + } + if (menuResult.mode === "verify-flagged") { + await deps.runActionPanel("Problem Account Check", "Checking problem accounts", async () => { + await deps.runVerifyFlagged([]); + }, displaySettings); + continue; + } + if (menuResult.mode === "fresh" && menuResult.deleteAll) { + await deps.runActionPanel("Reset Accounts", "Deleting all saved accounts", async () => { + await deps.clearAccountsAndReset(); + console.log("Cleared saved accounts from active storage. Recovery snapshots remain available."); + }, displaySettings); + continue; + } + if (menuResult.mode === "manage") { + const requiresInteractiveOAuth = typeof menuResult.refreshAccountIndex === "number"; + if (requiresInteractiveOAuth) { + await deps.handleManageAction(currentStorage, menuResult); + continue; + } + await deps.runActionPanel("Applying Change", "Updating selected account", async () => { + await deps.handleManageAction(currentStorage, menuResult); + }, displaySettings); + continue; + } + if (menuResult.mode === "add") { + break; + } + } + } + + const refreshedStorage = await loadAccounts(); + let existingCount = refreshedStorage?.accounts.length ?? 0; + let forceNewLogin = existingCount > 0; + let onboardingBackupDiscoveryWarning: string | null = null; + const loadNamedBackupsForOnboarding = async (): Promise => { + if (existingCount > 0) { + onboardingBackupDiscoveryWarning = null; + return []; + } + try { + onboardingBackupDiscoveryWarning = null; + return await getNamedBackups(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + deps.log.debug("getNamedBackups failed, skipping restore option", { + code, + error: error instanceof Error ? error.message : String(error), + }); + if (code && code !== "ENOENT") { + onboardingBackupDiscoveryWarning = + "Named backup discovery failed. Continuing with browser or manual sign-in only."; + console.warn(onboardingBackupDiscoveryWarning); + } else { + onboardingBackupDiscoveryWarning = null; + } + return []; + } + }; + let namedBackups = await loadNamedBackupsForOnboarding(); + while (true) { + const latestNamedBackup = namedBackups[0] ?? null; + const preferManualMode = loginOptions.manual || isBrowserLaunchSuppressed(); + const signInMode = preferManualMode + ? "manual" + : await deps.promptOAuthSignInMode( + latestNamedBackup, + onboardingBackupDiscoveryWarning, + ); + if (signInMode === "cancel") { + if (existingCount > 0) { + console.log(deps.stylePromptText(UI_COPY.oauth.cancelledBackToMenu, "muted")); + continue loginFlow; + } + console.log("Cancelled."); + return 0; + } + if (signInMode === "restore-backup") { + const latestAvailableBackup = namedBackups[0] ?? null; + if (!latestAvailableBackup) { + namedBackups = await loadNamedBackupsForOnboarding(); + continue; + } + const restoreMode = await deps.promptBackupRestoreMode(latestAvailableBackup); + if (restoreMode === "back") { + namedBackups = await loadNamedBackupsForOnboarding(); + continue; + } + + const selectedBackup = restoreMode === "manual" + ? await deps.promptManualBackupSelection(namedBackups) + : latestAvailableBackup; + if (!selectedBackup) { + namedBackups = await loadNamedBackupsForOnboarding(); + continue; + } + + const confirmed = await confirm( + UI_COPY.oauth.restoreBackupConfirm( + selectedBackup.fileName, + selectedBackup.accountCount, + ), + ); + if (!confirmed) { + namedBackups = await loadNamedBackupsForOnboarding(); + continue; + } + + const displaySettings = await loadDashboardDisplaySettings(); + applyUiThemeFromDashboardSettings(displaySettings); + try { + await deps.runActionPanel( + "Load Backup", + `Loading ${selectedBackup.fileName}`, + async () => { + const restoredStorage = await restoreAccountsFromBackup( + selectedBackup.path, + { persist: false }, + ); + const targetIndex = deps.resolveActiveIndex(restoredStorage); + const { synced } = await persistAndSyncSelectedAccount({ + storage: restoredStorage, + targetIndex, + parsed: targetIndex + 1, + switchReason: "restore", + preserveActiveIndexByFamily: true, + helpers: deps, + }); + console.log( + UI_COPY.oauth.restoreBackupLoaded( + selectedBackup.fileName, + restoredStorage.accounts.length, + ), + ); + if (!synced) { + console.warn(UI_COPY.oauth.restoreBackupSyncWarning); + } + }, + displaySettings, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (error instanceof StorageError) { + console.error(formatStorageErrorHint(error, selectedBackup.path)); + } else { + console.error(`Backup restore failed: ${message}`); + } + const storageAfterRestoreAttempt = await loadAccounts().catch(() => null); + if ((storageAfterRestoreAttempt?.accounts.length ?? 0) > 0) { + continue loginFlow; + } + namedBackups = await loadNamedBackupsForOnboarding(); + continue; + } + continue loginFlow; + } + + if (signInMode !== "browser" && signInMode !== "manual") { + continue; + } + + const tokenResult = await deps.runOAuthFlow(forceNewLogin, signInMode); + if (tokenResult.type !== "success") { + const message = tokenResult.message ?? tokenResult.reason ?? "unknown error"; + if (message.toLowerCase().includes("cancelled")) { + if (existingCount > 0) { + console.log(deps.stylePromptText(UI_COPY.oauth.cancelledBackToMenu, "muted")); + continue loginFlow; + } + console.log("Cancelled."); + return 0; + } + console.error(`Login failed: ${message}`); + return 1; + } + + const resolved = deps.resolveAccountSelection(tokenResult); + await deps.persistAccountPool([resolved], false); + await deps.syncSelectionToCodex(resolved); + + const latestStorage = await loadAccounts(); + const count = latestStorage?.accounts.length ?? 1; + existingCount = count; + namedBackups = []; + onboardingBackupDiscoveryWarning = null; + console.log(`Added account. Total: ${count}`); + console.log("Next steps:"); + console.log(" codex auth status Check that the wrapper is active."); + console.log(" codex auth check Confirm your saved accounts look healthy."); + console.log(" codex auth list Review saved accounts before switching."); + if (count >= ACCOUNT_LIMITS.MAX_ACCOUNTS) { + console.log(`Reached maximum account limit (${ACCOUNT_LIMITS.MAX_ACCOUNTS}).`); + break; + } + + const addAnother = await promptAddAnotherAccount(count); + if (!addAnother) break; + forceNewLogin = true; + } + continue loginFlow; + } +} diff --git a/lib/codex-manager/help.ts b/lib/codex-manager/help.ts new file mode 100644 index 00000000..6756114b --- /dev/null +++ b/lib/codex-manager/help.ts @@ -0,0 +1,136 @@ +export function printUsage(): void { + console.log( + [ + "Codex Multi-Auth CLI", + "", + "Start here:", + " codex auth login [--manual|--no-browser]", + " codex auth status", + " codex auth check", + "", + "Daily use:", + " codex auth list", + " codex auth switch ", + " codex auth best [--live] [--json] [--model ]", + " codex auth forecast [--live] [--json] [--model ]", + "", + "Repair:", + " codex auth verify-flagged [--dry-run] [--json] [--no-restore]", + " codex auth fix [--dry-run] [--json] [--live] [--model ]", + " codex auth doctor [--json] [--fix] [--dry-run]", + "", + "Advanced:", + " codex auth report [--live] [--json] [--model ] [--out ]", + " codex auth features", + "", + "Notes:", + " - Uses ~/.codex/multi-auth/openai-codex-accounts.json", + " - Syncs active account into Codex CLI auth state", + " - See docs/reference/commands.md for the full command and flag matrix", + ].join("\n"), + ); +} + +export type AuthLoginOptions = { + manual: boolean; +}; + +export type ParsedAuthLoginArgs = + | { ok: true; options: AuthLoginOptions } + | { ok: false; message: string }; + +export function parseAuthLoginArgs(args: string[]): ParsedAuthLoginArgs { + const options: AuthLoginOptions = { + manual: false, + }; + + for (const arg of args) { + if (arg === "--manual" || arg === "--no-browser") { + options.manual = true; + continue; + } + if (arg === "--help" || arg === "-h") { + printUsage(); + return { ok: false, message: "" }; + } + return { + ok: false, + message: `Unknown login option: ${arg}`, + }; + } + + return { ok: true, options }; +} + +export interface BestCliOptions { + live: boolean; + json: boolean; + model: string; + modelProvided: boolean; +} + +export type ParsedBestArgs = + | { ok: true; options: BestCliOptions } + | { ok: false; message: string }; + +export function printBestUsage(): void { + console.log( + [ + "Usage:", + " codex auth best [--live] [--json] [--model ]", + "", + "Options:", + " --live, -l Probe live quota headers via Codex backend before switching", + " --json, -j Print machine-readable JSON output", + " --model, -m Probe model for live mode (default: gpt-5-codex)", + "", + "Behavior:", + " - Chooses the healthiest account using forecast scoring", + " - Switches to the recommended account when it is not already active", + ].join("\n"), + ); +} + +export function parseBestArgs(args: string[]): ParsedBestArgs { + const options: BestCliOptions = { + live: false, + json: false, + model: "gpt-5-codex", + modelProvided: false, + }; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (!arg) continue; + if (arg === "--live" || arg === "-l") { + options.live = true; + continue; + } + if (arg === "--json" || arg === "-j") { + options.json = true; + continue; + } + if (arg === "--model" || arg === "-m") { + const value = args[i + 1]; + if (!value) { + return { ok: false, message: "Missing value for --model" }; + } + options.model = value; + options.modelProvided = true; + i += 1; + continue; + } + if (arg.startsWith("--model=")) { + const value = arg.slice("--model=".length).trim(); + if (!value) { + return { ok: false, message: "Missing value for --model" }; + } + options.model = value; + options.modelProvided = true; + continue; + } + return { ok: false, message: `Unknown option: ${arg}` }; + } + + return { ok: true, options }; +} diff --git a/test/documentation.test.ts b/test/documentation.test.ts index 1c696d36..4920e3fc 100644 --- a/test/documentation.test.ts +++ b/test/documentation.test.ts @@ -241,26 +241,32 @@ describe("Documentation Integrity", () => { it("keeps fix command flag docs aligned across README, reference, and CLI usage text", () => { const readme = read("README.md"); const commandRef = read("docs/reference/commands.md"); - const managerPath = "lib/codex-manager.ts"; + const helpPath = "lib/codex-manager/help.ts"; + const authCommandsPath = "lib/codex-manager/auth-commands.ts"; expect( - existsSync(join(projectRoot, managerPath)), - `${managerPath} should exist`, + existsSync(join(projectRoot, helpPath)), + `${helpPath} should exist`, ).toBe(true); - const manager = read(managerPath); + expect( + existsSync(join(projectRoot, authCommandsPath)), + `${authCommandsPath} should exist`, + ).toBe(true); + const help = read(helpPath); + const authCommands = read(authCommandsPath); expect(readme).toContain("codex auth fix --live --model gpt-5-codex"); expect(commandRef).toContain("| `--live` | forecast, report, fix |"); expect(commandRef).toContain( "| `--model ` | forecast, report, fix |", ); - expect(manager).toContain("codex auth login"); - expect(manager).toContain( + expect(help).toContain("codex auth login"); + expect(help).toContain( "codex auth fix [--dry-run] [--json] [--live] [--model ]", ); - expect(manager).toContain( + expect(authCommands).toContain( "Missing index. Usage: codex auth switch ", ); - expect(manager).not.toContain("codex-multi-auth auth switch "); + expect(authCommands).not.toContain("codex-multi-auth auth switch "); }); it("documents stable overrides separately from advanced and internal overrides", () => { From d1d2a7a9f464a84bbe73ae2f0679068e82133c31 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 00:37:21 +0800 Subject: [PATCH 006/376] refactor: split forecast and report cli flows --- lib/codex-manager.ts | 579 +--------------- lib/codex-manager/forecast-report-commands.ts | 622 ++++++++++++++++++ 2 files changed, 648 insertions(+), 553 deletions(-) create mode 100644 lib/codex-manager/forecast-report-commands.ts diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index dd3f4bc1..5b2972d0 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -1,7 +1,6 @@ import { createInterface } from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; import { promises as fs, existsSync } from "node:fs"; -import { dirname, resolve } from "node:path"; import { createAuthorizationFlow, exchangeAuthorizationCode, @@ -34,8 +33,6 @@ import { evaluateForecastAccounts, isHardRefreshFailure, recommendForecastAccount, - summarizeForecast, - type ForecastAccountResult, } from "./forecast.js"; import { createLogger } from "./logger.js"; import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; @@ -85,6 +82,10 @@ import { runBest as runBestCommand, runSwitch as runSwitchCommand, } from "./codex-manager/auth-commands.js"; +import { + runForecast as runForecastCommand, + runReport as runReportCommand, +} from "./codex-manager/forecast-report-commands.js"; import { applyUiThemeFromDashboardSettings, resolveMenuLayoutMode } from "./codex-manager/settings-hub.js"; type TokenSuccess = Extract; @@ -260,20 +261,6 @@ function styleAccountDetailText(detail: string, fallbackTone: PromptTone = "mute return stylePromptText(compact, fallbackTone); } -function riskTone(level: ForecastAccountResult["riskLevel"]): "success" | "warning" | "danger" { - if (level === "low") return "success"; - if (level === "medium") return "warning"; - return "danger"; -} - -function availabilityTone( - availability: ForecastAccountResult["availability"], -): "success" | "warning" | "danger" { - if (availability === "ready") return "success"; - if (availability === "delayed") return "warning"; - return "danger"; -} - function formatQuotaSnapshotForDashboard( snapshot: Awaited>, settings: DashboardDisplaySettings, @@ -2063,12 +2050,6 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { ])); } -interface ForecastCliOptions { - live: boolean; - json: boolean; - model: string; -} - interface FixCliOptions { dryRun: boolean; json: boolean; @@ -2076,13 +2057,6 @@ interface FixCliOptions { model: string; } -interface ReportCliOptions { - live: boolean; - json: boolean; - model: string; - outPath?: string; -} - interface VerifyFlaggedCliOptions { dryRun: boolean; json: boolean; @@ -2091,20 +2065,6 @@ interface VerifyFlaggedCliOptions { type ParsedArgsResult = { ok: true; options: T } | { ok: false; message: string }; -function printForecastUsage(): void { - console.log( - [ - "Usage:", - " codex auth forecast [--live] [--json] [--model ]", - "", - "Options:", - " --live, -l Probe live quota headers via Codex backend", - " --json, -j Print machine-readable JSON output", - " --model, -m Probe model for live mode (default: gpt-5-codex)", - ].join("\n"), - ); -} - function printFixUsage(): void { console.log( [ @@ -2143,49 +2103,6 @@ function printVerifyFlaggedUsage(): void { ); } -function parseForecastArgs(args: string[]): ParsedArgsResult { - const options: ForecastCliOptions = { - live: false, - json: false, - model: "gpt-5-codex", - }; - - for (let i = 0; i < args.length; i += 1) { - const arg = args[i]; - if (!arg) continue; - if (!arg) continue; - if (arg === "--live" || arg === "-l") { - options.live = true; - continue; - } - if (arg === "--json" || arg === "-j") { - options.json = true; - continue; - } - if (arg === "--model" || arg === "-m") { - const value = args[i + 1]; - if (!value) { - return { ok: false, message: "Missing value for --model" }; - } - options.model = value; - i += 1; - continue; - } - if (arg.startsWith("--model=")) { - const value = arg.slice("--model=".length).trim(); - if (!value) { - return { ok: false, message: "Missing value for --model" }; - } - options.model = value; - continue; - } - return { ok: false, message: `Unknown option: ${arg}` }; - } - - return { ok: true, options }; -} - - function parseFixArgs(args: string[]): ParsedArgsResult { const options: FixCliOptions = { dryRun: false, @@ -2306,472 +2223,6 @@ function parseDoctorArgs(args: string[]): ParsedArgsResult { return { ok: true, options }; } -function printReportUsage(): void { - console.log( - [ - "Usage:", - " codex auth report [--live] [--json] [--model ] [--out ]", - "", - "Options:", - " --live, -l Probe live quota headers via Codex backend", - " --json, -j Print machine-readable JSON output", - " --model, -m Probe model for live mode (default: gpt-5-codex)", - " --out Write JSON report to a file path", - ].join("\n"), - ); -} - -function parseReportArgs(args: string[]): ParsedArgsResult { - const options: ReportCliOptions = { - live: false, - json: false, - model: "gpt-5-codex", - }; - - for (let i = 0; i < args.length; i += 1) { - const arg = args[i]; - if (!arg) continue; - if (arg === "--live" || arg === "-l") { - options.live = true; - continue; - } - if (arg === "--json" || arg === "-j") { - options.json = true; - continue; - } - if (arg === "--model" || arg === "-m") { - const value = args[i + 1]; - if (!value) { - return { ok: false, message: "Missing value for --model" }; - } - options.model = value; - i += 1; - continue; - } - if (arg.startsWith("--model=")) { - const value = arg.slice("--model=".length).trim(); - if (!value) { - return { ok: false, message: "Missing value for --model" }; - } - options.model = value; - continue; - } - if (arg === "--out") { - const value = args[i + 1]; - if (!value) { - return { ok: false, message: "Missing value for --out" }; - } - options.outPath = value; - i += 1; - continue; - } - if (arg.startsWith("--out=")) { - const value = arg.slice("--out=".length).trim(); - if (!value) { - return { ok: false, message: "Missing value for --out" }; - } - options.outPath = value; - continue; - } - return { ok: false, message: `Unknown option: ${arg}` }; - } - - return { ok: true, options }; -} - -function serializeForecastResults( - results: ForecastAccountResult[], - liveQuotaByIndex: Map>>, - refreshFailures: Map, -): Array<{ - index: number; - label: string; - isCurrent: boolean; - availability: ForecastAccountResult["availability"]; - riskScore: number; - riskLevel: ForecastAccountResult["riskLevel"]; - waitMs: number; - reasons: string[]; - liveQuota?: { - status: number; - planType?: string; - activeLimit?: number; - model: string; - summary: string; - }; - refreshFailure?: TokenFailure; -}> { - return results.map((result) => { - const liveQuota = liveQuotaByIndex.get(result.index); - return { - index: result.index, - label: result.label, - isCurrent: result.isCurrent, - availability: result.availability, - riskScore: result.riskScore, - riskLevel: result.riskLevel, - waitMs: result.waitMs, - reasons: result.reasons, - liveQuota: liveQuota - ? { - status: liveQuota.status, - planType: liveQuota.planType, - activeLimit: liveQuota.activeLimit, - model: liveQuota.model, - summary: formatQuotaSnapshotLine(liveQuota), - } - : undefined, - refreshFailure: refreshFailures.get(result.index), - }; - }); -} - -async function runForecast(args: string[]): Promise { - if (args.includes("--help") || args.includes("-h")) { - printForecastUsage(); - return 0; - } - - const parsedArgs = parseForecastArgs(args); - if (!parsedArgs.ok) { - console.error(parsedArgs.message); - printForecastUsage(); - return 1; - } - const options = parsedArgs.options; - const display = DEFAULT_DASHBOARD_DISPLAY_SETTINGS; - const quotaCache = options.live ? await loadQuotaCache() : null; - const workingQuotaCache = quotaCache ? cloneQuotaCacheData(quotaCache) : null; - let quotaCacheChanged = false; - - setStoragePath(null); - const storage = await loadAccounts(); - if (!storage || storage.accounts.length === 0) { - console.log("No accounts configured."); - return 0; - } - const quotaEmailFallbackState = - options.live && quotaCache - ? buildQuotaEmailFallbackState(storage.accounts) - : null; - - const now = Date.now(); - const activeIndex = resolveActiveIndex(storage, "codex"); - const refreshFailures = new Map(); - const liveQuotaByIndex = new Map>>(); - const probeErrors: string[] = []; - - for (let i = 0; i < storage.accounts.length; i += 1) { - const account = storage.accounts[i]; - if (!account || !options.live) continue; - if (account.enabled === false) continue; - - let probeAccessToken = account.accessToken; - let probeAccountId = account.accountId ?? extractAccountId(account.accessToken); - if (!hasUsableAccessToken(account, now)) { - const refreshResult = await queuedRefresh(account.refreshToken); - if (refreshResult.type !== "success") { - refreshFailures.set(i, { - ...refreshResult, - message: normalizeFailureDetail(refreshResult.message, refreshResult.reason), - }); - continue; - } - probeAccessToken = refreshResult.access; - probeAccountId = account.accountId ?? extractAccountId(refreshResult.access); - } - - if (!probeAccessToken || !probeAccountId) { - probeErrors.push(`${formatAccountLabel(account, i)}: missing accountId for live probe`); - continue; - } - - try { - const liveQuota = await fetchCodexQuotaSnapshot({ - accountId: probeAccountId, - accessToken: probeAccessToken, - model: options.model, - }); - liveQuotaByIndex.set(i, liveQuota); - if (workingQuotaCache) { - const account = storage.accounts[i]; - if (account) { - quotaCacheChanged = - updateQuotaCacheForAccount( - workingQuotaCache, - account, - liveQuota, - storage.accounts, - quotaEmailFallbackState ?? undefined, - ) || quotaCacheChanged; - } - } - } catch (error) { - const message = normalizeFailureDetail( - error instanceof Error ? error.message : String(error), - undefined, - ); - probeErrors.push(`${formatAccountLabel(account, i)}: ${message}`); - } - } - - const forecastInputs = storage.accounts.map((account, index) => ({ - index, - account, - isCurrent: index === activeIndex, - now, - refreshFailure: refreshFailures.get(index), - liveQuota: liveQuotaByIndex.get(index), - })); - const forecastResults = evaluateForecastAccounts(forecastInputs); - const summary = summarizeForecast(forecastResults); - const recommendation = recommendForecastAccount(forecastResults); - - if (options.json) { - if (workingQuotaCache && quotaCacheChanged) { - await saveQuotaCache(workingQuotaCache); - } - console.log( - JSON.stringify( - { - command: "forecast", - model: options.model, - liveProbe: options.live, - summary, - recommendation, - probeErrors, - accounts: serializeForecastResults(forecastResults, liveQuotaByIndex, refreshFailures), - }, - null, - 2, - ), - ); - return 0; - } - - console.log( - stylePromptText( - `Best-account preview (${storage.accounts.length} account(s), model ${options.model}, live check ${options.live ? "on" : "off"})`, - "accent", - ), - ); - console.log( - formatResultSummary([ - { text: `${summary.ready} ready now`, tone: "success" }, - { text: `${summary.delayed} waiting`, tone: "warning" }, - { text: `${summary.unavailable} unavailable`, tone: summary.unavailable > 0 ? "danger" : "muted" }, - { text: `${summary.highRisk} high risk`, tone: summary.highRisk > 0 ? "danger" : "muted" }, - ]), - ); - console.log(""); - - for (const result of forecastResults) { - if (!display.showPerAccountRows) { - continue; - } - const currentTag = result.isCurrent ? " [current]" : ""; - const waitLabel = result.waitMs > 0 ? stylePromptText(`wait ${formatWaitTime(result.waitMs)}`, "muted") : ""; - const indexLabel = stylePromptText(`${result.index + 1}.`, "accent"); - const accountLabel = stylePromptText(`${result.label}${currentTag}`, "accent"); - const riskLabel = stylePromptText(`${result.riskLevel} risk (${result.riskScore})`, riskTone(result.riskLevel)); - const availabilityLabel = stylePromptText(result.availability, availabilityTone(result.availability)); - const rowParts = [availabilityLabel, riskLabel]; - if (waitLabel) rowParts.push(waitLabel); - console.log(`${indexLabel} ${accountLabel} ${stylePromptText("|", "muted")} ${joinStyledSegments(rowParts)}`); - if (display.showForecastReasons && result.reasons.length > 0) { - console.log(` ${stylePromptText(result.reasons.slice(0, 3).join("; "), "muted")}`); - } - const liveQuota = liveQuotaByIndex.get(result.index); - if (display.showQuotaDetails && liveQuota) { - console.log(` ${stylePromptText("quota:", "accent")} ${styleQuotaSummary(formatCompactQuotaSnapshot(liveQuota))}`); - } - } - - if (!display.showPerAccountRows) { - console.log(stylePromptText("Per-account lines are hidden in dashboard settings.", "muted")); - } - - if (display.showRecommendations) { - console.log(""); - if (recommendation.recommendedIndex !== null) { - const index = recommendation.recommendedIndex; - const account = forecastResults.find((result) => result.index === index); - if (account) { - console.log( - `${stylePromptText("Best next account:", "accent")} ${stylePromptText(`${index + 1} (${account.label})`, "success")}`, - ); - console.log(`${stylePromptText("Why:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`); - if (index !== activeIndex) { - console.log(`${stylePromptText("Switch now with:", "accent")} codex auth switch ${index + 1}`); - } - } - } else { - console.log(`${stylePromptText("Note:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`); - } - } - - if (display.showLiveProbeNotes && probeErrors.length > 0) { - console.log(""); - console.log(stylePromptText(`Live check notes (${probeErrors.length}):`, "warning")); - for (const error of probeErrors) { - console.log(` ${stylePromptText("-", "warning")} ${stylePromptText(error, "muted")}`); - } - } - if (workingQuotaCache && quotaCacheChanged) { - await saveQuotaCache(workingQuotaCache); - } - - return 0; -} - -async function runReport(args: string[]): Promise { - if (args.includes("--help") || args.includes("-h")) { - printReportUsage(); - return 0; - } - - const parsedArgs = parseReportArgs(args); - if (!parsedArgs.ok) { - console.error(parsedArgs.message); - printReportUsage(); - return 1; - } - const options = parsedArgs.options; - - setStoragePath(null); - const storagePath = getStoragePath(); - const storage = await loadAccounts(); - const now = Date.now(); - const accountCount = storage?.accounts.length ?? 0; - const activeIndex = storage ? resolveActiveIndex(storage, "codex") : 0; - const refreshFailures = new Map(); - const liveQuotaByIndex = new Map>>(); - const probeErrors: string[] = []; - - if (storage && options.live) { - for (let i = 0; i < storage.accounts.length; i += 1) { - const account = storage.accounts[i]; - if (!account || account.enabled === false) continue; - - const refreshResult = await queuedRefresh(account.refreshToken); - if (refreshResult.type !== "success") { - refreshFailures.set(i, { - ...refreshResult, - message: normalizeFailureDetail(refreshResult.message, refreshResult.reason), - }); - continue; - } - - const accountId = account.accountId ?? extractAccountId(refreshResult.access); - if (!accountId) { - probeErrors.push(`${formatAccountLabel(account, i)}: missing accountId for live probe`); - continue; - } - - try { - const liveQuota = await fetchCodexQuotaSnapshot({ - accountId, - accessToken: refreshResult.access, - model: options.model, - }); - liveQuotaByIndex.set(i, liveQuota); - } catch (error) { - const message = normalizeFailureDetail( - error instanceof Error ? error.message : String(error), - undefined, - ); - probeErrors.push(`${formatAccountLabel(account, i)}: ${message}`); - } - } - } - - const forecastResults = storage - ? evaluateForecastAccounts( - storage.accounts.map((account, index) => ({ - index, - account, - isCurrent: index === activeIndex, - now, - refreshFailure: refreshFailures.get(index), - liveQuota: liveQuotaByIndex.get(index), - })), - ) - : []; - const forecastSummary = summarizeForecast(forecastResults); - const recommendation = recommendForecastAccount(forecastResults); - const enabledCount = storage - ? storage.accounts.filter((account) => account.enabled !== false).length - : 0; - const disabledCount = Math.max(0, accountCount - enabledCount); - const coolingCount = storage - ? storage.accounts.filter( - (account) => - typeof account.coolingDownUntil === "number" && account.coolingDownUntil > now, - ).length - : 0; - const rateLimitedCount = storage - ? storage.accounts.filter((account) => !!formatRateLimitEntry(account, now, "codex")).length - : 0; - - const report = { - command: "report", - generatedAt: new Date(now).toISOString(), - storagePath, - model: options.model, - liveProbe: options.live, - accounts: { - total: accountCount, - enabled: enabledCount, - disabled: disabledCount, - coolingDown: coolingCount, - rateLimited: rateLimitedCount, - }, - activeIndex: accountCount > 0 ? activeIndex + 1 : null, - forecast: { - summary: forecastSummary, - recommendation, - probeErrors, - accounts: serializeForecastResults(forecastResults, liveQuotaByIndex, refreshFailures), - }, - }; - - if (options.outPath) { - const outputPath = resolve(process.cwd(), options.outPath); - await fs.mkdir(dirname(outputPath), { recursive: true }); - await fs.writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, "utf-8"); - } - - if (options.json) { - console.log(JSON.stringify(report, null, 2)); - return 0; - } - - console.log(`Report generated at ${report.generatedAt}`); - console.log(`Storage: ${report.storagePath}`); - console.log( - `Accounts: ${report.accounts.total} total (${report.accounts.enabled} enabled, ${report.accounts.disabled} disabled, ${report.accounts.coolingDown} cooling, ${report.accounts.rateLimited} rate-limited)`, - ); - if (report.activeIndex !== null) { - console.log(`Active account: ${report.activeIndex}`); - } - console.log( - `Forecast: ${report.forecast.summary.ready} ready, ${report.forecast.summary.delayed} delayed, ${report.forecast.summary.unavailable} unavailable`, - ); - if (report.forecast.recommendation.recommendedIndex !== null) { - console.log( - `Recommendation: account ${report.forecast.recommendation.recommendedIndex + 1} (${report.forecast.recommendation.reason})`, - ); - } else { - console.log(`Recommendation: ${report.forecast.recommendation.reason}`); - } - if (options.outPath) { - console.log(`Report written: ${resolve(process.cwd(), options.outPath)}`); - } - if (report.forecast.probeErrors.length > 0) { - console.log(`Probe notes: ${report.forecast.probeErrors.length}`); - } - return 0; -} type FixOutcome = | "healthy" @@ -4248,6 +3699,14 @@ async function runBest(args: string[]): Promise { return runBestCommand(args, authCommandHelpers); } +async function runForecast(args: string[]): Promise { + return runForecastCommand(args, forecastReportCommandDeps); +} + +async function runReport(args: string[]): Promise { + return runReportCommand(args, forecastReportCommandDeps); +} + export async function autoSyncActiveAccountToCodex(): Promise { setStoragePath(null); const storage = await loadAccounts(); @@ -4348,6 +3807,20 @@ const authLoginCommandDeps = { log, }; +const forecastReportCommandDeps = { + stylePromptText, + styleQuotaSummary, + formatResultSummary, + resolveActiveIndex, + hasUsableAccessToken, + normalizeFailureDetail, + buildQuotaEmailFallbackState, + updateQuotaCacheForAccount, + cloneQuotaCacheData, + formatCompactQuotaSnapshot, + formatRateLimitEntry, +}; + export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { const startupDisplaySettings = await loadDashboardDisplaySettings(); applyUiThemeFromDashboardSettings(startupDisplaySettings); diff --git a/lib/codex-manager/forecast-report-commands.ts b/lib/codex-manager/forecast-report-commands.ts new file mode 100644 index 00000000..586f1243 --- /dev/null +++ b/lib/codex-manager/forecast-report-commands.ts @@ -0,0 +1,622 @@ +import { promises as fs } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { + extractAccountId, + formatAccountLabel, + formatWaitTime, +} from "../accounts.js"; +import { DEFAULT_DASHBOARD_DISPLAY_SETTINGS } from "../dashboard-settings.js"; +import { + evaluateForecastAccounts, + recommendForecastAccount, + summarizeForecast, + type ForecastAccountResult, +} from "../forecast.js"; +import { loadQuotaCache, saveQuotaCache, type QuotaCacheData } from "../quota-cache.js"; +import { fetchCodexQuotaSnapshot, formatQuotaSnapshotLine } from "../quota-probe.js"; +import { queuedRefresh } from "../refresh-queue.js"; +import { + getStoragePath, + loadAccounts, + setStoragePath, + type AccountMetadataV3, + type AccountStorageV3, +} from "../storage.js"; +import type { TokenFailure } from "../types.js"; +import type { ModelFamily } from "../prompts/codex.js"; + +type PromptTone = "accent" | "success" | "warning" | "danger" | "muted"; + +type QuotaEmailFallbackState = { + matchingCount: number; + distinctAccountIds: Set; +}; + +type QuotaCacheAccountRef = Pick & { + email?: string; +}; + +export interface ForecastCliOptions { + live: boolean; + json: boolean; + model: string; +} + +export interface ReportCliOptions { + live: boolean; + json: boolean; + model: string; + outPath?: string; +} + +type ParsedArgsResult = { ok: true; options: T } | { ok: false; message: string }; + +export interface ForecastReportCommandDeps { + stylePromptText: (text: string, tone: PromptTone) => string; + styleQuotaSummary: (summary: string) => string; + formatResultSummary: ( + segments: ReadonlyArray<{ text: string; tone: PromptTone }>, + ) => string; + resolveActiveIndex: ( + storage: AccountStorageV3, + family?: ModelFamily, + ) => number; + hasUsableAccessToken: ( + account: AccountMetadataV3, + now: number, + ) => boolean; + normalizeFailureDetail: ( + message: string | undefined, + reason: string | undefined, + ) => string; + buildQuotaEmailFallbackState: ( + accounts: readonly QuotaCacheAccountRef[], + ) => ReadonlyMap; + updateQuotaCacheForAccount: ( + cache: QuotaCacheData, + account: QuotaCacheAccountRef, + snapshot: Awaited>, + accounts: readonly QuotaCacheAccountRef[], + emailFallbackState?: ReadonlyMap, + ) => boolean; + cloneQuotaCacheData: (cache: QuotaCacheData) => QuotaCacheData; + formatCompactQuotaSnapshot: ( + snapshot: Awaited>, + ) => string; + formatRateLimitEntry: ( + account: AccountMetadataV3, + now: number, + family: ModelFamily, + ) => string | null; +} + +export function printForecastUsage(): void { + console.log( + [ + "Usage:", + " codex auth forecast [--live] [--json] [--model ]", + "", + "Options:", + " --live, -l Probe live quota headers via Codex backend", + " --json, -j Print machine-readable JSON output", + " --model, -m Probe model for live mode (default: gpt-5-codex)", + ].join("\n"), + ); +} + +export function printReportUsage(): void { + console.log( + [ + "Usage:", + " codex auth report [--live] [--json] [--model ] [--out ]", + "", + "Options:", + " --live, -l Probe live quota headers via Codex backend", + " --json, -j Print machine-readable JSON output", + " --model, -m Probe model for live mode (default: gpt-5-codex)", + " --out Write JSON report to a file path", + ].join("\n"), + ); +} + +export function parseForecastArgs(args: string[]): ParsedArgsResult { + const options: ForecastCliOptions = { + live: false, + json: false, + model: "gpt-5-codex", + }; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (!arg) continue; + if (arg === "--live" || arg === "-l") { + options.live = true; + continue; + } + if (arg === "--json" || arg === "-j") { + options.json = true; + continue; + } + if (arg === "--model" || arg === "-m") { + const value = args[i + 1]; + if (!value) { + return { ok: false, message: "Missing value for --model" }; + } + options.model = value; + i += 1; + continue; + } + if (arg.startsWith("--model=")) { + const value = arg.slice("--model=".length).trim(); + if (!value) { + return { ok: false, message: "Missing value for --model" }; + } + options.model = value; + continue; + } + return { ok: false, message: `Unknown option: ${arg}` }; + } + + return { ok: true, options }; +} + +export function parseReportArgs(args: string[]): ParsedArgsResult { + const options: ReportCliOptions = { + live: false, + json: false, + model: "gpt-5-codex", + }; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (!arg) continue; + if (arg === "--live" || arg === "-l") { + options.live = true; + continue; + } + if (arg === "--json" || arg === "-j") { + options.json = true; + continue; + } + if (arg === "--model" || arg === "-m") { + const value = args[i + 1]; + if (!value) { + return { ok: false, message: "Missing value for --model" }; + } + options.model = value; + i += 1; + continue; + } + if (arg.startsWith("--model=")) { + const value = arg.slice("--model=".length).trim(); + if (!value) { + return { ok: false, message: "Missing value for --model" }; + } + options.model = value; + continue; + } + if (arg === "--out") { + const value = args[i + 1]; + if (!value) { + return { ok: false, message: "Missing value for --out" }; + } + options.outPath = value; + i += 1; + continue; + } + if (arg.startsWith("--out=")) { + const value = arg.slice("--out=".length).trim(); + if (!value) { + return { ok: false, message: "Missing value for --out" }; + } + options.outPath = value; + continue; + } + return { ok: false, message: `Unknown option: ${arg}` }; + } + + return { ok: true, options }; +} + +function serializeForecastResults( + results: ForecastAccountResult[], + liveQuotaByIndex: Map>>, + refreshFailures: Map, +): Array<{ + index: number; + label: string; + isCurrent: boolean; + availability: ForecastAccountResult["availability"]; + riskScore: number; + riskLevel: ForecastAccountResult["riskLevel"]; + waitMs: number; + reasons: string[]; + liveQuota?: { + status: number; + planType?: string; + activeLimit?: number; + model: string; + summary: string; + }; + refreshFailure?: TokenFailure; +}> { + return results.map((result) => { + const liveQuota = liveQuotaByIndex.get(result.index); + return { + index: result.index, + label: result.label, + isCurrent: result.isCurrent, + availability: result.availability, + riskScore: result.riskScore, + riskLevel: result.riskLevel, + waitMs: result.waitMs, + reasons: result.reasons, + liveQuota: liveQuota + ? { + status: liveQuota.status, + planType: liveQuota.planType, + activeLimit: liveQuota.activeLimit, + model: liveQuota.model, + summary: formatQuotaSnapshotLine(liveQuota), + } + : undefined, + refreshFailure: refreshFailures.get(result.index), + }; + }); +} + +export async function runForecast( + args: string[], + deps: ForecastReportCommandDeps, +): Promise { + if (args.includes("--help") || args.includes("-h")) { + printForecastUsage(); + return 0; + } + + const parsedArgs = parseForecastArgs(args); + if (!parsedArgs.ok) { + console.error(parsedArgs.message); + printForecastUsage(); + return 1; + } + const options = parsedArgs.options; + const display = DEFAULT_DASHBOARD_DISPLAY_SETTINGS; + const quotaCache = options.live ? await loadQuotaCache() : null; + const workingQuotaCache = quotaCache ? deps.cloneQuotaCacheData(quotaCache) : null; + let quotaCacheChanged = false; + + setStoragePath(null); + const storage = await loadAccounts(); + if (!storage || storage.accounts.length === 0) { + console.log("No accounts configured."); + return 0; + } + const quotaEmailFallbackState = + options.live && quotaCache + ? deps.buildQuotaEmailFallbackState(storage.accounts) + : null; + + const now = Date.now(); + const activeIndex = deps.resolveActiveIndex(storage, "codex"); + const refreshFailures = new Map(); + const liveQuotaByIndex = new Map>>(); + const probeErrors: string[] = []; + + for (let i = 0; i < storage.accounts.length; i += 1) { + const account = storage.accounts[i]; + if (!account || !options.live) continue; + if (account.enabled === false) continue; + + let probeAccessToken = account.accessToken; + let probeAccountId = account.accountId ?? extractAccountId(account.accessToken); + if (!deps.hasUsableAccessToken(account, now)) { + const refreshResult = await queuedRefresh(account.refreshToken); + if (refreshResult.type !== "success") { + refreshFailures.set(i, { + ...refreshResult, + message: deps.normalizeFailureDetail(refreshResult.message, refreshResult.reason), + }); + continue; + } + probeAccessToken = refreshResult.access; + probeAccountId = account.accountId ?? extractAccountId(refreshResult.access); + } + + if (!probeAccessToken || !probeAccountId) { + probeErrors.push(`${formatAccountLabel(account, i)}: missing accountId for live probe`); + continue; + } + + try { + const liveQuota = await fetchCodexQuotaSnapshot({ + accountId: probeAccountId, + accessToken: probeAccessToken, + model: options.model, + }); + liveQuotaByIndex.set(i, liveQuota); + if (workingQuotaCache) { + const currentAccount = storage.accounts[i]; + if (currentAccount) { + quotaCacheChanged = + deps.updateQuotaCacheForAccount( + workingQuotaCache, + currentAccount, + liveQuota, + storage.accounts, + quotaEmailFallbackState ?? undefined, + ) || quotaCacheChanged; + } + } + } catch (error) { + const message = deps.normalizeFailureDetail( + error instanceof Error ? error.message : String(error), + undefined, + ); + probeErrors.push(`${formatAccountLabel(account, i)}: ${message}`); + } + } + + const forecastInputs = storage.accounts.map((account, index) => ({ + index, + account, + isCurrent: index === activeIndex, + now, + refreshFailure: refreshFailures.get(index), + liveQuota: liveQuotaByIndex.get(index), + })); + const forecastResults = evaluateForecastAccounts(forecastInputs); + const summary = summarizeForecast(forecastResults); + const recommendation = recommendForecastAccount(forecastResults); + + if (options.json) { + if (workingQuotaCache && quotaCacheChanged) { + await saveQuotaCache(workingQuotaCache); + } + console.log( + JSON.stringify( + { + command: "forecast", + model: options.model, + liveProbe: options.live, + summary, + recommendation, + probeErrors, + accounts: serializeForecastResults(forecastResults, liveQuotaByIndex, refreshFailures), + }, + null, + 2, + ), + ); + return 0; + } + + console.log( + deps.stylePromptText( + `Best-account preview (${storage.accounts.length} account(s), model ${options.model}, live check ${options.live ? "on" : "off"})`, + "accent", + ), + ); + console.log( + deps.formatResultSummary([ + { text: `${summary.ready} ready now`, tone: "success" }, + { text: `${summary.delayed} waiting`, tone: "warning" }, + { text: `${summary.unavailable} unavailable`, tone: summary.unavailable > 0 ? "danger" : "muted" }, + { text: `${summary.highRisk} high risk`, tone: summary.highRisk > 0 ? "danger" : "muted" }, + ]), + ); + console.log(""); + + for (const result of forecastResults) { + if (!display.showPerAccountRows) continue; + const currentTag = result.isCurrent ? " [current]" : ""; + const waitLabel = result.waitMs > 0 ? deps.stylePromptText(`wait ${formatWaitTime(result.waitMs)}`, "muted") : ""; + const indexLabel = deps.stylePromptText(`${result.index + 1}.`, "accent"); + const accountLabel = deps.stylePromptText(`${result.label}${currentTag}`, "accent"); + const riskTone: PromptTone = result.riskLevel === "low" ? "success" : result.riskLevel === "medium" ? "warning" : "danger"; + const availabilityTone: PromptTone = result.availability === "ready" ? "success" : result.availability === "delayed" ? "warning" : "danger"; + const rowParts = [ + deps.stylePromptText(result.availability, availabilityTone), + deps.stylePromptText(`${result.riskLevel} risk (${result.riskScore})`, riskTone), + ]; + if (waitLabel) rowParts.push(waitLabel); + console.log(`${indexLabel} ${accountLabel} ${deps.stylePromptText("|", "muted")} ${rowParts.join(deps.stylePromptText(" | ", "muted"))}`); + if (display.showForecastReasons && result.reasons.length > 0) { + console.log(` ${deps.stylePromptText(result.reasons.slice(0, 3).join("; "), "muted")}`); + } + const liveQuota = liveQuotaByIndex.get(result.index); + if (display.showQuotaDetails && liveQuota) { + console.log(` ${deps.stylePromptText("quota:", "accent")} ${deps.styleQuotaSummary(deps.formatCompactQuotaSnapshot(liveQuota))}`); + } + } + + if (!display.showPerAccountRows) { + console.log(deps.stylePromptText("Per-account lines are hidden in dashboard settings.", "muted")); + } + + if (display.showRecommendations) { + console.log(""); + if (recommendation.recommendedIndex !== null) { + const index = recommendation.recommendedIndex; + const account = forecastResults.find((result) => result.index === index); + if (account) { + console.log( + `${deps.stylePromptText("Best next account:", "accent")} ${deps.stylePromptText(`${index + 1} (${account.label})`, "success")}`, + ); + console.log(`${deps.stylePromptText("Why:", "accent")} ${deps.stylePromptText(recommendation.reason, "muted")}`); + if (index !== activeIndex) { + console.log(`${deps.stylePromptText("Switch now with:", "accent")} codex auth switch ${index + 1}`); + } + } + } else { + console.log(`${deps.stylePromptText("Note:", "accent")} ${deps.stylePromptText(recommendation.reason, "muted")}`); + } + } + + if (display.showLiveProbeNotes && probeErrors.length > 0) { + console.log(""); + console.log(deps.stylePromptText(`Live check notes (${probeErrors.length}):`, "warning")); + for (const error of probeErrors) { + console.log(` ${deps.stylePromptText("-", "warning")} ${deps.stylePromptText(error, "muted")}`); + } + } + + if (workingQuotaCache && quotaCacheChanged) { + await saveQuotaCache(workingQuotaCache); + } + + return 0; +} + +export async function runReport( + args: string[], + deps: ForecastReportCommandDeps, +): Promise { + if (args.includes("--help") || args.includes("-h")) { + printReportUsage(); + return 0; + } + + const parsedArgs = parseReportArgs(args); + if (!parsedArgs.ok) { + console.error(parsedArgs.message); + printReportUsage(); + return 1; + } + const options = parsedArgs.options; + + setStoragePath(null); + const storagePath = getStoragePath(); + const storage = await loadAccounts(); + const now = Date.now(); + const accountCount = storage?.accounts.length ?? 0; + const activeIndex = storage ? deps.resolveActiveIndex(storage, "codex") : 0; + const refreshFailures = new Map(); + const liveQuotaByIndex = new Map>>(); + const probeErrors: string[] = []; + + if (storage && options.live) { + for (let i = 0; i < storage.accounts.length; i += 1) { + const account = storage.accounts[i]; + if (!account || account.enabled === false) continue; + + const refreshResult = await queuedRefresh(account.refreshToken); + if (refreshResult.type !== "success") { + refreshFailures.set(i, { + ...refreshResult, + message: deps.normalizeFailureDetail(refreshResult.message, refreshResult.reason), + }); + continue; + } + + const accountId = account.accountId ?? extractAccountId(refreshResult.access); + if (!accountId) { + probeErrors.push(`${formatAccountLabel(account, i)}: missing accountId for live probe`); + continue; + } + + try { + const liveQuota = await fetchCodexQuotaSnapshot({ + accountId, + accessToken: refreshResult.access, + model: options.model, + }); + liveQuotaByIndex.set(i, liveQuota); + } catch (error) { + const message = deps.normalizeFailureDetail( + error instanceof Error ? error.message : String(error), + undefined, + ); + probeErrors.push(`${formatAccountLabel(account, i)}: ${message}`); + } + } + } + + const forecastResults = storage + ? evaluateForecastAccounts( + storage.accounts.map((account, index) => ({ + index, + account, + isCurrent: index === activeIndex, + now, + refreshFailure: refreshFailures.get(index), + liveQuota: liveQuotaByIndex.get(index), + })), + ) + : []; + const forecastSummary = summarizeForecast(forecastResults); + const recommendation = recommendForecastAccount(forecastResults); + const enabledCount = storage + ? storage.accounts.filter((account) => account.enabled !== false).length + : 0; + const disabledCount = Math.max(0, accountCount - enabledCount); + const coolingCount = storage + ? storage.accounts.filter( + (account) => + typeof account.coolingDownUntil === "number" && account.coolingDownUntil > now, + ).length + : 0; + const rateLimitedCount = storage + ? storage.accounts.filter((account) => !!deps.formatRateLimitEntry(account, now, "codex")).length + : 0; + + const report = { + command: "report", + generatedAt: new Date(now).toISOString(), + storagePath, + model: options.model, + liveProbe: options.live, + accounts: { + total: accountCount, + enabled: enabledCount, + disabled: disabledCount, + coolingDown: coolingCount, + rateLimited: rateLimitedCount, + }, + activeIndex: accountCount > 0 ? activeIndex + 1 : null, + forecast: { + summary: forecastSummary, + recommendation, + probeErrors, + accounts: serializeForecastResults(forecastResults, liveQuotaByIndex, refreshFailures), + }, + }; + + if (options.outPath) { + const outputPath = resolve(process.cwd(), options.outPath); + await fs.mkdir(dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, "utf-8"); + } + + if (options.json) { + console.log(JSON.stringify(report, null, 2)); + return 0; + } + + console.log(`Report generated at ${report.generatedAt}`); + console.log(`Storage: ${report.storagePath}`); + console.log( + `Accounts: ${report.accounts.total} total (${report.accounts.enabled} enabled, ${report.accounts.disabled} disabled, ${report.accounts.coolingDown} cooling, ${report.accounts.rateLimited} rate-limited)`, + ); + if (report.activeIndex !== null) { + console.log(`Active account: ${report.activeIndex}`); + } + console.log( + `Forecast: ${report.forecast.summary.ready} ready, ${report.forecast.summary.delayed} delayed, ${report.forecast.summary.unavailable} unavailable`, + ); + if (report.forecast.recommendation.recommendedIndex !== null) { + console.log( + `Recommendation: account ${report.forecast.recommendation.recommendedIndex + 1} (${report.forecast.recommendation.reason})`, + ); + } else { + console.log(`Recommendation: ${report.forecast.recommendation.reason}`); + } + if (options.outPath) { + console.log(`Report written: ${resolve(process.cwd(), options.outPath)}`); + } + if (report.forecast.probeErrors.length > 0) { + console.log(`Probe notes: ${report.forecast.probeErrors.length}`); + } + + return 0; +} From 9a7d3964f8e17409cc0ace527542e9c6d48fe9a5 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 00:54:59 +0800 Subject: [PATCH 007/376] refactor: split repair and doctor cli flows --- lib/codex-manager.ts | 1846 +++----------------------- lib/codex-manager/repair-commands.ts | 1751 ++++++++++++++++++++++++ 2 files changed, 1899 insertions(+), 1698 deletions(-) create mode 100644 lib/codex-manager/repair-commands.ts diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 5b2972d0..b8a91c52 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -1,6 +1,5 @@ import { createInterface } from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; -import { promises as fs, existsSync } from "node:fs"; import { createAuthorizationFlow, exchangeAuthorizationCode, @@ -22,18 +21,12 @@ import { selectBestAccountCandidate, shouldUpdateAccountIdFromToken, } from "./accounts.js"; -import { ACCOUNT_LIMITS } from "./constants.js"; import { loadDashboardDisplaySettings, DEFAULT_DASHBOARD_DISPLAY_SETTINGS, type DashboardDisplaySettings, type DashboardAccountSortMode, } from "./dashboard-settings.js"; -import { - evaluateForecastAccounts, - isHardRefreshFailure, - recommendForecastAccount, -} from "./forecast.js"; import { createLogger } from "./logger.js"; import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; import { @@ -52,24 +45,15 @@ import { clearAccounts, findMatchingAccountIndex, getStoragePath, - loadFlaggedAccounts, loadAccounts, type NamedBackupSummary, - saveFlaggedAccounts, saveAccounts, setStoragePath, - withAccountAndFlaggedStorageTransaction, withAccountStorageTransaction, type AccountMetadataV3, type AccountStorageV3, - type FlaggedAccountMetadataV1, } from "./storage.js"; -import type { AccountIdSource, TokenFailure, TokenResult } from "./types.js"; -import { - getCodexCliAuthPath, - getCodexCliConfigPath, - loadCodexCliState, -} from "./codex-cli/state.js"; +import type { AccountIdSource, TokenResult } from "./types.js"; import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; import { ANSI } from "./ui/ansi.js"; import { UI_COPY } from "./ui/copy.js"; @@ -86,6 +70,11 @@ import { runForecast as runForecastCommand, runReport as runReportCommand, } from "./codex-manager/forecast-report-commands.js"; +import { + runDoctor as runDoctorCommand, + runFix as runFixCommand, + runVerifyFlagged as runVerifyFlaggedCommand, +} from "./codex-manager/repair-commands.js"; import { applyUiThemeFromDashboardSettings, resolveMenuLayoutMode } from "./codex-manager/settings-hub.js"; type TokenSuccess = Extract; @@ -2050,1730 +2039,174 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { ])); } -interface FixCliOptions { - dryRun: boolean; - json: boolean; - live: boolean; - model: string; -} - -interface VerifyFlaggedCliOptions { - dryRun: boolean; - json: boolean; - restore: boolean; -} - -type ParsedArgsResult = { ok: true; options: T } | { ok: false; message: string }; - -function printFixUsage(): void { - console.log( - [ - "Usage:", - " codex auth fix [--dry-run] [--json] [--live] [--model ]", - "", - "Options:", - " --dry-run, -n Preview changes without writing storage", - " --json, -j Print machine-readable JSON output", - " --live, -l Run live session probe before deciding health", - " --model, -m Probe model for live mode (default: gpt-5-codex)", - "", - "Behavior:", - " - Refreshes tokens for enabled accounts", - " - Disables hard-failed accounts (never deletes)", - " - Recommends a better current account when needed", - ].join("\n"), - ); +async function runVerifyFlagged(args: string[]): Promise { + return runVerifyFlaggedCommand(args, repairCommandDeps); } -function printVerifyFlaggedUsage(): void { - console.log( - [ - "Usage:", - " codex auth verify-flagged [--dry-run] [--json] [--no-restore]", - "", - "Options:", - " --dry-run, -n Preview changes without writing storage", - " --json, -j Print machine-readable JSON output", - " --no-restore Check flagged accounts without restoring healthy ones", - "", - "Behavior:", - " - Refresh-checks accounts from flagged storage", - " - Restores healthy accounts back to active storage by default", - ].join("\n"), - ); +async function runFix(args: string[]): Promise { + return runFixCommand(args, repairCommandDeps); } -function parseFixArgs(args: string[]): ParsedArgsResult { - const options: FixCliOptions = { - dryRun: false, - json: false, - live: false, - model: "gpt-5-codex", - }; - - for (let i = 0; i < args.length; i += 1) { - const argValue = args[i]; - if (typeof argValue !== "string") continue; - if (argValue === "--dry-run" || argValue === "-n") { - options.dryRun = true; - continue; - } - if (argValue === "--json" || argValue === "-j") { - options.json = true; - continue; - } - if (argValue === "--live" || argValue === "-l") { - options.live = true; - continue; - } - if (argValue === "--model" || argValue === "-m") { - const value = args[i + 1]; - if (!value) { - return { ok: false, message: "Missing value for --model" }; - } - options.model = value; - i += 1; - continue; - } - if (argValue.startsWith("--model=")) { - const value = argValue.slice("--model=".length).trim(); - if (!value) { - return { ok: false, message: "Missing value for --model" }; - } - options.model = value; - continue; - } - return { ok: false, message: `Unknown option: ${argValue}` }; - } +async function runDoctor(args: string[]): Promise { + return runDoctorCommand(args, repairCommandDeps); +} - return { ok: true, options }; +async function clearAccountsAndReset(): Promise { + await clearAccounts(); } -function parseVerifyFlaggedArgs(args: string[]): ParsedArgsResult { - const options: VerifyFlaggedCliOptions = { - dryRun: false, - json: false, - restore: true, - }; +async function handleManageAction( + storage: AccountStorageV3, + menuResult: Awaited>, +): Promise { + if (typeof menuResult.switchAccountIndex === "number") { + const index = menuResult.switchAccountIndex; + await runSwitch([String(index + 1)]); + return; + } - for (const arg of args) { - if (arg === "--dry-run" || arg === "-n") { - options.dryRun = true; - continue; - } - if (arg === "--json" || arg === "-j") { - options.json = true; - continue; - } - if (arg === "--no-restore") { - options.restore = false; - continue; + if (typeof menuResult.deleteAccountIndex === "number") { + const idx = menuResult.deleteAccountIndex; + if (idx >= 0 && idx < storage.accounts.length) { + storage.accounts.splice(idx, 1); + storage.activeIndex = 0; + storage.activeIndexByFamily = {}; + for (const family of MODEL_FAMILIES) { + storage.activeIndexByFamily[family] = 0; + } + await saveAccounts(storage); + console.log(`Deleted account ${idx + 1}.`); } - return { ok: false, message: `Unknown option: ${arg}` }; - } - - return { ok: true, options }; -} - -interface DoctorCliOptions { - json: boolean; - fix: boolean; - dryRun: boolean; -} - -function printDoctorUsage(): void { - console.log( - [ - "Usage:", - " codex auth doctor [--json] [--fix] [--dry-run]", - "", - "Options:", - " --json, -j Print machine-readable JSON diagnostics", - " --fix Apply safe auto-fixes to storage", - " --dry-run, -n Preview --fix changes without writing storage", - "", - "Behavior:", - " - Validates account storage readability", - " - Checks active index consistency and account duplication", - " - Flags placeholder/demo accounts and disabled-all scenarios", - ].join("\n"), - ); -} + return; + } -function parseDoctorArgs(args: string[]): ParsedArgsResult { - const options: DoctorCliOptions = { json: false, fix: false, dryRun: false }; - for (const arg of args) { - if (arg === "--json" || arg === "-j") { - options.json = true; - continue; - } - if (arg === "--fix") { - options.fix = true; - continue; - } - if (arg === "--dry-run" || arg === "-n") { - options.dryRun = true; - continue; + if (typeof menuResult.toggleAccountIndex === "number") { + const idx = menuResult.toggleAccountIndex; + const account = storage.accounts[idx]; + if (account) { + account.enabled = account.enabled === false; + await saveAccounts(storage); + console.log( + `${account.enabled === false ? "Disabled" : "Enabled"} account ${idx + 1}.`, + ); } - return { ok: false, message: `Unknown option: ${arg}` }; - } - if (options.dryRun && !options.fix) { - return { ok: false, message: "--dry-run requires --fix" }; + return; } - return { ok: true, options }; -} + if (typeof menuResult.refreshAccountIndex === "number") { + const idx = menuResult.refreshAccountIndex; + const existing = storage.accounts[idx]; + if (!existing) return; -type FixOutcome = - | "healthy" - | "disabled-hard-failure" - | "warning-soft-failure" - | "already-disabled"; + const signInMode = await promptOAuthSignInMode(null); + if (signInMode === "cancel") { + console.log(stylePromptText(UI_COPY.oauth.cancelledBackToMenu, "muted")); + return; + } + if (signInMode !== "browser" && signInMode !== "manual") { + return; + } -interface FixAccountReport { - index: number; - label: string; - outcome: FixOutcome; - message: string; -} + const tokenResult = await runOAuthFlow(true, signInMode); + if (tokenResult.type !== "success") { + console.error(`Refresh failed: ${tokenResult.message ?? tokenResult.reason ?? "unknown error"}`); + return; + } -function summarizeFixReports( - reports: FixAccountReport[], -): { - healthy: number; - disabled: number; - warnings: number; - skipped: number; -} { - let healthy = 0; - let disabled = 0; - let warnings = 0; - let skipped = 0; - for (const report of reports) { - if (report.outcome === "healthy") healthy += 1; - else if (report.outcome === "disabled-hard-failure") disabled += 1; - else if (report.outcome === "warning-soft-failure") warnings += 1; - else skipped += 1; + const resolved = resolveAccountSelection(tokenResult); + await persistAccountPool([resolved], false); + await syncSelectionToCodex(resolved); + console.log(`Refreshed account ${idx + 1}.`); } - return { healthy, disabled, warnings, skipped }; } -interface VerifyFlaggedReport { - index: number; - label: string; - outcome: "restored" | "healthy-flagged" | "still-flagged" | "restore-skipped"; - message: string; +async function runAuthLogin(args: string[]): Promise { + return runAuthLoginCommand(args, authLoginCommandDeps); } -function createEmptyAccountStorage(): AccountStorageV3 { - const activeIndexByFamily: Partial> = {}; - for (const family of MODEL_FAMILIES) { - activeIndexByFamily[family] = 0; - } - return { - version: 3, - accounts: [], - activeIndex: 0, - activeIndexByFamily, - }; +async function runSwitch(args: string[]): Promise { + return runSwitchCommand(args, authCommandHelpers); } -function findExistingAccountIndexForFlagged( - storage: AccountStorageV3, - flagged: FlaggedAccountMetadataV1, - nextRefreshToken: string, - nextAccountId: string | undefined, - nextEmail: string | undefined, -): number { - const flaggedEmail = sanitizeEmail(flagged.email); - const candidateAccountId = nextAccountId ?? flagged.accountId; - const candidateEmail = sanitizeEmail(nextEmail) ?? flaggedEmail; - const nextMatchIndex = findMatchingAccountIndex(storage.accounts, { - accountId: candidateAccountId, - email: candidateEmail, - refreshToken: nextRefreshToken, - }, { - allowUniqueAccountIdFallbackWithoutEmail: true, - }); - if (nextMatchIndex !== undefined) { - return nextMatchIndex; - } - - const flaggedMatchIndex = findMatchingAccountIndex(storage.accounts, { - accountId: candidateAccountId, - email: candidateEmail, - refreshToken: flagged.refreshToken, - }, { - allowUniqueAccountIdFallbackWithoutEmail: true, - }); - return flaggedMatchIndex ?? -1; +async function runBest(args: string[]): Promise { + return runBestCommand(args, authCommandHelpers); } -function upsertRecoveredFlaggedAccount( - storage: AccountStorageV3, - flagged: FlaggedAccountMetadataV1, - refreshResult: TokenSuccess, - now: number, -): { restored: boolean; changed: boolean; message: string } { - const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)) ?? flagged.email; - const tokenAccountId = extractAccountId(refreshResult.access); - const { accountId: nextAccountId, accountIdSource: nextAccountIdSource } = - resolveStoredAccountIdentity(flagged.accountId, flagged.accountIdSource, tokenAccountId); - const existingIndex = findExistingAccountIndexForFlagged( - storage, - flagged, - refreshResult.refresh, - nextAccountId, - nextEmail, - ); - - if (existingIndex >= 0) { - const existing = storage.accounts[existingIndex]; - if (!existing) { - return { restored: false, changed: false, message: "existing account entry is missing" }; - } - let changed = false; - if (existing.refreshToken !== refreshResult.refresh) { - existing.refreshToken = refreshResult.refresh; - changed = true; - } - if (existing.accessToken !== refreshResult.access) { - existing.accessToken = refreshResult.access; - changed = true; - } - if (existing.expiresAt !== refreshResult.expires) { - existing.expiresAt = refreshResult.expires; - changed = true; - } - if (nextEmail && nextEmail !== existing.email) { - existing.email = nextEmail; - changed = true; - } - if ( - nextAccountId !== undefined && - ( - (nextAccountId !== existing.accountId) - || (nextAccountIdSource !== existing.accountIdSource) - ) - ) { - existing.accountId = nextAccountId; - existing.accountIdSource = nextAccountIdSource; - changed = true; - } - if (existing.enabled === false) { - existing.enabled = true; - changed = true; - } - if (existing.accountLabel !== flagged.accountLabel && flagged.accountLabel) { - existing.accountLabel = flagged.accountLabel; - changed = true; - } - existing.lastUsed = now; - return { - restored: true, - changed, - message: `restored into existing account ${existingIndex + 1}`, - }; - } - - if (storage.accounts.length >= ACCOUNT_LIMITS.MAX_ACCOUNTS) { - return { - restored: false, - changed: false, - message: `cannot restore (max ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts reached)`, - }; - } +async function runForecast(args: string[]): Promise { + return runForecastCommand(args, forecastReportCommandDeps); +} - storage.accounts.push({ - refreshToken: refreshResult.refresh, - accessToken: refreshResult.access, - expiresAt: refreshResult.expires, - accountId: nextAccountId, - accountIdSource: nextAccountIdSource, - accountLabel: flagged.accountLabel, - email: nextEmail, - addedAt: flagged.addedAt ?? now, - lastUsed: now, - enabled: true, - }); - return { - restored: true, - changed: true, - message: `restored as account ${storage.accounts.length}`, - }; +async function runReport(args: string[]): Promise { + return runReportCommand(args, forecastReportCommandDeps); } -async function runVerifyFlagged(args: string[]): Promise { - if (args.includes("--help") || args.includes("-h")) { - printVerifyFlaggedUsage(); - return 0; +export async function autoSyncActiveAccountToCodex(): Promise { + setStoragePath(null); + const storage = await loadAccounts(); + if (!storage || storage.accounts.length === 0) { + return false; } - const parsedArgs = parseVerifyFlaggedArgs(args); - if (!parsedArgs.ok) { - console.error(parsedArgs.message); - printVerifyFlaggedUsage(); - return 1; + const activeIndex = resolveActiveIndex(storage, "codex"); + if (activeIndex < 0 || activeIndex >= storage.accounts.length) { + return false; } - const options = parsedArgs.options; - setStoragePath(null); - const flaggedStorage = await loadFlaggedAccounts(); - if (flaggedStorage.accounts.length === 0) { - if (options.json) { - console.log( - JSON.stringify( - { - command: "verify-flagged", - total: 0, - restored: 0, - healthyFlagged: 0, - stillFlagged: 0, - changed: false, - dryRun: options.dryRun, - restore: options.restore, - reports: [] as VerifyFlaggedReport[], - }, - null, - 2, - ), - ); - return 0; - } - console.log("No flagged accounts to check."); - return 0; + const account = storage.accounts[activeIndex]; + if (!account) { + return false; } - let storageChanged = false; - let flaggedChanged = false; - const reports: VerifyFlaggedReport[] = []; - const nextFlaggedAccounts: FlaggedAccountMetadataV1[] = []; const now = Date.now(); - const refreshChecks: Array<{ - index: number; - flagged: FlaggedAccountMetadataV1; - label: string; - result: Awaited>; - }> = []; - - for (let i = 0; i < flaggedStorage.accounts.length; i += 1) { - const flagged = flaggedStorage.accounts[i]; - if (!flagged) continue; - const label = formatAccountLabel(flagged, i); - refreshChecks.push({ - index: i, - flagged, - label, - result: await queuedRefresh(flagged.refreshToken), - }); - } - - const applyRefreshChecks = ( - storage: AccountStorageV3, - ): void => { - for (const check of refreshChecks) { - const { index: i, flagged, label, result } = check; - if (result.type === "success") { - if (!options.restore) { - const tokenAccountId = extractAccountId(result.access); - const nextIdentity = resolveStoredAccountIdentity( - flagged.accountId, - flagged.accountIdSource, - tokenAccountId, - ); - const nextFlagged: FlaggedAccountMetadataV1 = { - ...flagged, - refreshToken: result.refresh, - accessToken: result.access, - expiresAt: result.expires, - accountId: nextIdentity.accountId, - accountIdSource: nextIdentity.accountIdSource, - email: sanitizeEmail(extractAccountEmail(result.access, result.idToken)) ?? flagged.email, - lastUsed: now, - lastError: undefined, - }; - nextFlaggedAccounts.push(nextFlagged); - if (JSON.stringify(nextFlagged) !== JSON.stringify(flagged)) { - flaggedChanged = true; - } - reports.push({ - index: i, - label, - outcome: "healthy-flagged", - message: "session is healthy (left in flagged list due to --no-restore)", - }); - continue; - } - - const upsertResult = upsertRecoveredFlaggedAccount(storage, flagged, result, now); - if (upsertResult.restored) { - storageChanged = storageChanged || upsertResult.changed; - flaggedChanged = true; - reports.push({ - index: i, - label, - outcome: "restored", - message: upsertResult.message, - }); - continue; - } + let syncAccessToken = account.accessToken; + let syncRefreshToken = account.refreshToken; + let syncExpiresAt = account.expiresAt; + let syncIdToken: string | undefined; + let changed = false; - const tokenAccountId = extractAccountId(result.access); - const nextIdentity = resolveStoredAccountIdentity( - flagged.accountId, - flagged.accountIdSource, - tokenAccountId, - ); - const updatedFlagged: FlaggedAccountMetadataV1 = { - ...flagged, - refreshToken: result.refresh, - accessToken: result.access, - expiresAt: result.expires, - accountId: nextIdentity.accountId, - accountIdSource: nextIdentity.accountIdSource, - email: sanitizeEmail(extractAccountEmail(result.access, result.idToken)) ?? flagged.email, - lastUsed: now, - lastError: upsertResult.message, - }; - nextFlaggedAccounts.push(updatedFlagged); - if (JSON.stringify(updatedFlagged) !== JSON.stringify(flagged)) { - flaggedChanged = true; - } - reports.push({ - index: i, - label, - outcome: "restore-skipped", - message: upsertResult.message, - }); - continue; + if (!hasUsableAccessToken(account, now)) { + const refreshResult = await queuedRefresh(account.refreshToken); + if (refreshResult.type === "success") { + const tokenAccountId = extractAccountId(refreshResult.access); + const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)); + if (account.refreshToken !== refreshResult.refresh) { + account.refreshToken = refreshResult.refresh; + changed = true; } - - const detail = normalizeFailureDetail(result.message, result.reason); - const failedFlagged: FlaggedAccountMetadataV1 = { - ...flagged, - lastError: detail, - }; - nextFlaggedAccounts.push(failedFlagged); - if ((flagged.lastError ?? "") !== detail) { - flaggedChanged = true; + if (account.accessToken !== refreshResult.access) { + account.accessToken = refreshResult.access; + changed = true; } - reports.push({ - index: i, - label, - outcome: "still-flagged", - message: detail, - }); - } - }; - - if (options.restore) { - if (options.dryRun) { - applyRefreshChecks( - (await loadAccounts()) ?? createEmptyAccountStorage(), - ); - } else { - await withAccountAndFlaggedStorageTransaction( - async (loadedStorage, persist) => { - const nextStorage = loadedStorage - ? structuredClone(loadedStorage) - : createEmptyAccountStorage(); - applyRefreshChecks(nextStorage); - if (!storageChanged) { - return; - } - normalizeDoctorIndexes(nextStorage); - await persist(nextStorage, { - version: 1, - accounts: nextFlaggedAccounts, - }); - }, - ); + if (account.expiresAt !== refreshResult.expires) { + account.expiresAt = refreshResult.expires; + changed = true; + } + if (nextEmail && nextEmail !== account.email) { + account.email = nextEmail; + changed = true; + } + if (applyTokenAccountIdentity(account, tokenAccountId)) { + changed = true; + } + syncAccessToken = refreshResult.access; + syncRefreshToken = refreshResult.refresh; + syncExpiresAt = refreshResult.expires; + syncIdToken = refreshResult.idToken; } - } else { - applyRefreshChecks(createEmptyAccountStorage()); - } - - const remainingFlagged = nextFlaggedAccounts.length; - const restored = reports.filter((report) => report.outcome === "restored").length; - const healthyFlagged = reports.filter((report) => report.outcome === "healthy-flagged").length; - const stillFlagged = reports.filter((report) => report.outcome === "still-flagged").length; - const changed = storageChanged || flaggedChanged; - - if (!options.dryRun && flaggedChanged && (!options.restore || !storageChanged)) { - await saveFlaggedAccounts({ - version: 1, - accounts: nextFlaggedAccounts, - }); - } - - if (options.json) { - console.log( - JSON.stringify( - { - command: "verify-flagged", - total: flaggedStorage.accounts.length, - restored, - healthyFlagged, - stillFlagged, - remainingFlagged, - changed, - dryRun: options.dryRun, - restore: options.restore, - reports, - }, - null, - 2, - ), - ); - return 0; } - console.log( - stylePromptText( - `Checking ${flaggedStorage.accounts.length} flagged account(s)...`, - "accent", - ), - ); - for (const report of reports) { - const tone = report.outcome === "restored" - ? "success" - : report.outcome === "healthy-flagged" - ? "warning" - : report.outcome === "restore-skipped" - ? "warning" - : "danger"; - const marker = report.outcome === "restored" - ? "✓" - : report.outcome === "healthy-flagged" - ? "!" - : report.outcome === "restore-skipped" - ? "!" - : "✗"; - console.log( - `${stylePromptText(marker, tone)} ${stylePromptText(`${report.index + 1}. ${report.label}`, "accent")} ${stylePromptText("|", "muted")} ${styleAccountDetailText(report.message, tone)}`, - ); - } - console.log(""); - console.log(formatResultSummary([ - { text: `${restored} restored`, tone: restored > 0 ? "success" : "muted" }, - { text: `${healthyFlagged} healthy (kept flagged)`, tone: healthyFlagged > 0 ? "warning" : "muted" }, - { text: `${stillFlagged} still flagged`, tone: stillFlagged > 0 ? "danger" : "muted" }, - ])); - if (options.dryRun) { - console.log(stylePromptText("Preview only: no changes were saved.", "warning")); - } else if (!changed) { - console.log(stylePromptText("No storage changes were needed.", "muted")); + if (changed) { + await saveAccounts(storage); } - return 0; -} - -async function runFix(args: string[]): Promise { - if (args.includes("--help") || args.includes("-h")) { - printFixUsage(); - return 0; - } - - const parsedArgs = parseFixArgs(args); - if (!parsedArgs.ok) { - console.error(parsedArgs.message); - printFixUsage(); - return 1; - } - const options = parsedArgs.options; - const display = DEFAULT_DASHBOARD_DISPLAY_SETTINGS; - const quotaCache = options.live ? await loadQuotaCache() : null; - const workingQuotaCache = quotaCache ? cloneQuotaCacheData(quotaCache) : null; - let quotaCacheChanged = false; - - setStoragePath(null); - const storage = await loadAccounts(); - if (!storage || storage.accounts.length === 0) { - console.log("No accounts configured."); - return 0; - } - let quotaEmailFallbackState = - options.live && quotaCache - ? buildQuotaEmailFallbackState(storage.accounts) - : null; - - const now = Date.now(); - const activeIndex = resolveActiveIndex(storage, "codex"); - let changed = false; - const reports: FixAccountReport[] = []; - const refreshFailures = new Map(); - const hardDisabledIndexes: number[] = []; - - for (let i = 0; i < storage.accounts.length; i += 1) { - const account = storage.accounts[i]; - if (!account) continue; - const label = formatAccountLabel(account, i); - - if (account.enabled === false) { - reports.push({ - index: i, - label, - outcome: "already-disabled", - message: "already disabled", - }); - continue; - } - - if (hasUsableAccessToken(account, now)) { - if (options.live) { - const currentAccessToken = account.accessToken; - const probeAccountId = currentAccessToken - ? (account.accountId ?? extractAccountId(currentAccessToken)) - : undefined; - if (probeAccountId && currentAccessToken) { - try { - const snapshot = await fetchCodexQuotaSnapshot({ - accountId: probeAccountId, - accessToken: currentAccessToken, - model: options.model, - }); - if (workingQuotaCache) { - quotaCacheChanged = - updateQuotaCacheForAccount( - workingQuotaCache, - account, - snapshot, - storage.accounts, - quotaEmailFallbackState ?? undefined, - ) || quotaCacheChanged; - } - reports.push({ - index: i, - label, - outcome: "healthy", - message: display.showQuotaDetails - ? `live session OK (${formatCompactQuotaSnapshot(snapshot)})` - : "live session OK", - }); - continue; - } catch (error) { - const message = normalizeFailureDetail( - error instanceof Error ? error.message : String(error), - undefined, - ); - reports.push({ - index: i, - label, - outcome: "warning-soft-failure", - message: `live probe failed (${message}), trying refresh fallback`, - }); - } - } - } - - const refreshWarning = hasLikelyInvalidRefreshToken(account.refreshToken) - ? " (refresh token looks stale; re-login recommended)" - : ""; - reports.push({ - index: i, - label, - outcome: "healthy", - message: `access token still valid${refreshWarning}`, - }); - continue; - } - - const refreshResult = await queuedRefresh(account.refreshToken); - if (refreshResult.type === "success") { - const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)); - const nextAccountId = extractAccountId(refreshResult.access); - const previousEmail = account.email; - let accountChanged = false; - let accountIdentityChanged = false; - - if (account.refreshToken !== refreshResult.refresh) { - account.refreshToken = refreshResult.refresh; - accountChanged = true; - } - if (account.accessToken !== refreshResult.access) { - account.accessToken = refreshResult.access; - accountChanged = true; - } - if (account.expiresAt !== refreshResult.expires) { - account.expiresAt = refreshResult.expires; - accountChanged = true; - } - if (nextEmail && nextEmail !== account.email) { - account.email = nextEmail; - accountChanged = true; - accountIdentityChanged = true; - } - if (applyTokenAccountIdentity(account, nextAccountId)) { - accountChanged = true; - accountIdentityChanged = true; - } - - if (accountChanged) changed = true; - if (accountIdentityChanged && options.live && workingQuotaCache) { - quotaEmailFallbackState = buildQuotaEmailFallbackState(storage.accounts); - quotaCacheChanged = - pruneUnsafeQuotaEmailCacheEntry( - workingQuotaCache, - previousEmail, - storage.accounts, - quotaEmailFallbackState, - ) || quotaCacheChanged; - } - if (options.live) { - const probeAccountId = account.accountId ?? nextAccountId; - if (probeAccountId) { - try { - const snapshot = await fetchCodexQuotaSnapshot({ - accountId: probeAccountId, - accessToken: refreshResult.access, - model: options.model, - }); - if (workingQuotaCache) { - quotaCacheChanged = - updateQuotaCacheForAccount( - workingQuotaCache, - account, - snapshot, - storage.accounts, - quotaEmailFallbackState ?? undefined, - ) || quotaCacheChanged; - } - reports.push({ - index: i, - label, - outcome: "healthy", - message: display.showQuotaDetails - ? `refresh + live probe succeeded (${formatCompactQuotaSnapshot(snapshot)})` - : "refresh + live probe succeeded", - }); - continue; - } catch (error) { - const message = normalizeFailureDetail( - error instanceof Error ? error.message : String(error), - undefined, - ); - reports.push({ - index: i, - label, - outcome: "warning-soft-failure", - message: `refresh succeeded but live probe failed: ${message}`, - }); - continue; - } - } - } - reports.push({ - index: i, - label, - outcome: "healthy", - message: "refresh succeeded", - }); - continue; - } - - const detail = normalizeFailureDetail(refreshResult.message, refreshResult.reason); - refreshFailures.set(i, { - ...refreshResult, - message: detail, - }); - if (isHardRefreshFailure(refreshResult)) { - account.enabled = false; - changed = true; - hardDisabledIndexes.push(i); - reports.push({ - index: i, - label, - outcome: "disabled-hard-failure", - message: detail, - }); - } else { - reports.push({ - index: i, - label, - outcome: "warning-soft-failure", - message: detail, - }); - } - } - - if (hardDisabledIndexes.length > 0) { - const enabledCount = storage.accounts.filter((account) => account.enabled !== false).length; - if (enabledCount === 0) { - const fallbackIndex = - hardDisabledIndexes.includes(activeIndex) ? activeIndex : hardDisabledIndexes[0]; - const fallback = typeof fallbackIndex === "number" - ? storage.accounts[fallbackIndex] - : undefined; - if (fallback && fallback.enabled === false) { - fallback.enabled = true; - changed = true; - const existingReport = reports.find( - (report) => - report.index === fallbackIndex && - report.outcome === "disabled-hard-failure", - ); - if (existingReport) { - existingReport.outcome = "warning-soft-failure"; - existingReport.message = `${existingReport.message} (kept enabled to avoid lockout; re-login required)`; - } - } - } - } - - const forecastResults = evaluateForecastAccounts( - storage.accounts.map((account, index) => ({ - index, - account, - isCurrent: index === activeIndex, - now, - refreshFailure: refreshFailures.get(index), - })), - ); - const recommendation = recommendForecastAccount(forecastResults); - const reportSummary = summarizeFixReports(reports); - - if (changed && !options.dryRun) { - await saveAccounts(storage); - } - - if (options.json) { - if (workingQuotaCache && quotaCacheChanged) { - await saveQuotaCache(workingQuotaCache); - } - console.log( - JSON.stringify( - { - command: "fix", - dryRun: options.dryRun, - liveProbe: options.live, - model: options.model, - changed, - summary: reportSummary, - recommendation, - recommendedSwitchCommand: - recommendation.recommendedIndex !== null && - recommendation.recommendedIndex !== activeIndex - ? `codex auth switch ${recommendation.recommendedIndex + 1}` - : null, - reports, - }, - null, - 2, - ), - ); - return 0; - } - - console.log(stylePromptText(`Auto-fix scan (${options.dryRun ? "preview" : "apply"})`, "accent")); - console.log(formatResultSummary([ - { text: `${reportSummary.healthy} working`, tone: "success" }, - { text: `${reportSummary.disabled} disabled`, tone: reportSummary.disabled > 0 ? "danger" : "muted" }, - { - text: `${reportSummary.warnings} warning${reportSummary.warnings === 1 ? "" : "s"}`, - tone: reportSummary.warnings > 0 ? "warning" : "muted", - }, - { text: `${reportSummary.skipped} already disabled`, tone: "muted" }, - ])); - if (display.showPerAccountRows) { - console.log(""); - for (const report of reports) { - const prefix = - report.outcome === "healthy" - ? "✓" - : report.outcome === "disabled-hard-failure" - ? "✗" - : report.outcome === "warning-soft-failure" - ? "!" - : "-"; - const tone = report.outcome === "healthy" - ? "success" - : report.outcome === "disabled-hard-failure" - ? "danger" - : report.outcome === "warning-soft-failure" - ? "warning" - : "muted"; - console.log( - `${stylePromptText(prefix, tone)} ${stylePromptText(`${report.index + 1}. ${report.label}`, "accent")} ${stylePromptText("|", "muted")} ${styleAccountDetailText(report.message, tone === "success" ? "muted" : tone)}`, - ); - } - } else { - console.log(""); - console.log(stylePromptText("Per-account lines are hidden in dashboard settings.", "muted")); - } - - if (display.showRecommendations) { - console.log(""); - if (recommendation.recommendedIndex !== null) { - const target = recommendation.recommendedIndex + 1; - console.log(`${stylePromptText("Best next account:", "accent")} ${stylePromptText(String(target), "success")}`); - console.log(`${stylePromptText("Why:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`); - if (recommendation.recommendedIndex !== activeIndex) { - console.log(`${stylePromptText("Switch now with:", "accent")} codex auth switch ${target}`); - } - } else { - console.log(`${stylePromptText("Note:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`); - } - } - if (workingQuotaCache && quotaCacheChanged) { - await saveQuotaCache(workingQuotaCache); - } - - if (changed && options.dryRun) { - console.log(`\n${stylePromptText("Preview only: no changes were saved.", "warning")}`); - } else if (changed) { - console.log(`\n${stylePromptText("Saved updates.", "success")}`); - } else { - console.log(`\n${stylePromptText("No changes were needed.", "muted")}`); - } - - return 0; -} - -type DoctorSeverity = "ok" | "warn" | "error"; - -interface DoctorCheck { - key: string; - severity: DoctorSeverity; - message: string; - details?: string; -} - -interface DoctorFixAction { - key: string; - message: string; -} - -function hasPlaceholderEmail(value: string | undefined): boolean { - if (!value) return false; - const email = value.trim().toLowerCase(); - if (!email) return false; - return ( - email.endsWith("@example.com") || - email.includes("account1@example.com") || - email.includes("account2@example.com") || - email.includes("account3@example.com") - ); -} - -function normalizeDoctorIndexes(storage: AccountStorageV3): boolean { - const total = storage.accounts.length; - const nextActive = total === 0 ? 0 : Math.max(0, Math.min(storage.activeIndex, total - 1)); - let changed = false; - if (storage.activeIndex !== nextActive) { - storage.activeIndex = nextActive; - changed = true; - } - storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; - for (const family of MODEL_FAMILIES) { - const raw = storage.activeIndexByFamily[family]; - const fallback = storage.activeIndex; - const candidate = typeof raw === "number" && Number.isFinite(raw) ? raw : fallback; - const clamped = total === 0 ? 0 : Math.max(0, Math.min(candidate, total - 1)); - if (storage.activeIndexByFamily[family] !== clamped) { - storage.activeIndexByFamily[family] = clamped; - changed = true; - } - } - return changed; -} - -function getDoctorRefreshTokenKey( - refreshToken: unknown, -): string | undefined { - if (typeof refreshToken !== "string") return undefined; - const trimmed = refreshToken.trim(); - return trimmed || undefined; -} - -function applyDoctorFixes(storage: AccountStorageV3): { changed: boolean; actions: DoctorFixAction[] } { - let changed = false; - const actions: DoctorFixAction[] = []; - - if (normalizeDoctorIndexes(storage)) { - changed = true; - actions.push({ - key: "active-index", - message: "Normalized active account indexes", - }); - } - - const seenRefreshTokens = new Map(); - for (let i = 0; i < storage.accounts.length; i += 1) { - const account = storage.accounts[i]; - if (!account) continue; - - const refreshToken = getDoctorRefreshTokenKey(account.refreshToken); - if (!refreshToken) continue; - const existingTokenIndex = seenRefreshTokens.get(refreshToken); - if (typeof existingTokenIndex === "number") { - if (account.enabled !== false) { - account.enabled = false; - changed = true; - actions.push({ - key: "duplicate-refresh-token", - message: `Disabled duplicate token entry on account ${i + 1} (kept account ${existingTokenIndex + 1})`, - }); - } - } else { - seenRefreshTokens.set(refreshToken, i); - } - - const tokenEmail = sanitizeEmail(extractAccountEmail(account.accessToken)); - if ( - tokenEmail && - (!sanitizeEmail(account.email) || hasPlaceholderEmail(account.email)) - ) { - account.email = tokenEmail; - changed = true; - actions.push({ - key: "email-from-token", - message: `Updated account ${i + 1} email from token claims`, - }); - } - - const tokenAccountId = extractAccountId(account.accessToken); - if (!account.accountId && tokenAccountId) { - account.accountId = tokenAccountId; - account.accountIdSource = "token"; - changed = true; - actions.push({ - key: "account-id-from-token", - message: `Filled missing accountId for account ${i + 1}`, - }); - } - } - - const enabledCount = storage.accounts.filter((account) => account.enabled !== false).length; - if (storage.accounts.length > 0 && enabledCount === 0) { - const index = resolveActiveIndex(storage, "codex"); - const candidate = storage.accounts[index] ?? storage.accounts[0]; - if (candidate) { - candidate.enabled = true; - changed = true; - actions.push({ - key: "enabled-accounts", - message: `Re-enabled account ${index + 1} to avoid an all-disabled pool`, - }); - } - } - - if (normalizeDoctorIndexes(storage)) { - changed = true; - } - - return { changed, actions }; -} - -async function runDoctor(args: string[]): Promise { - if (args.includes("--help") || args.includes("-h")) { - printDoctorUsage(); - return 0; - } - - const parsedArgs = parseDoctorArgs(args); - if (!parsedArgs.ok) { - console.error(parsedArgs.message); - printDoctorUsage(); - return 1; - } - const options = parsedArgs.options; - - setStoragePath(null); - const storagePath = getStoragePath(); - const checks: DoctorCheck[] = []; - const addCheck = (check: DoctorCheck): void => { - checks.push(check); - }; - - addCheck({ - key: "storage-file", - severity: existsSync(storagePath) ? "ok" : "warn", - message: existsSync(storagePath) - ? "Account storage file found" - : "Account storage file does not exist yet (first login pending)", - details: storagePath, - }); - - if (existsSync(storagePath)) { - try { - const stat = await fs.stat(storagePath); - addCheck({ - key: "storage-readable", - severity: stat.size > 0 ? "ok" : "warn", - message: stat.size > 0 ? "Storage file is readable" : "Storage file is empty", - details: `${stat.size} bytes`, - }); - } catch (error) { - addCheck({ - key: "storage-readable", - severity: "error", - message: "Unable to read storage file metadata", - details: error instanceof Error ? error.message : String(error), - }); - } - } - - const codexAuthPath = getCodexCliAuthPath(); - const codexConfigPath = getCodexCliConfigPath(); - let codexAuthEmail: string | undefined; - let codexAuthAccountId: string | undefined; - - addCheck({ - key: "codex-auth-file", - severity: existsSync(codexAuthPath) ? "ok" : "warn", - message: existsSync(codexAuthPath) - ? "Codex auth file found" - : "Codex auth file does not exist", - details: codexAuthPath, - }); - - if (existsSync(codexAuthPath)) { - try { - const raw = await fs.readFile(codexAuthPath, "utf-8"); - const parsed = JSON.parse(raw) as unknown; - if (parsed && typeof parsed === "object") { - const payload = parsed as Record; - const tokens = payload.tokens && typeof payload.tokens === "object" - ? (payload.tokens as Record) - : null; - const accessToken = tokens && typeof tokens.access_token === "string" - ? tokens.access_token - : undefined; - const idToken = tokens && typeof tokens.id_token === "string" - ? tokens.id_token - : undefined; - const accountIdFromFile = tokens && typeof tokens.account_id === "string" - ? tokens.account_id - : undefined; - const emailFromFile = typeof payload.email === "string" ? payload.email : undefined; - codexAuthEmail = sanitizeEmail(emailFromFile ?? extractAccountEmail(accessToken, idToken)); - codexAuthAccountId = accountIdFromFile ?? extractAccountId(accessToken); - } - addCheck({ - key: "codex-auth-readable", - severity: "ok", - message: "Codex auth file is readable", - details: - codexAuthEmail || codexAuthAccountId - ? `email=${codexAuthEmail ?? "unknown"}, accountId=${codexAuthAccountId ?? "unknown"}` - : undefined, - }); - } catch (error) { - addCheck({ - key: "codex-auth-readable", - severity: "error", - message: "Unable to read Codex auth file", - details: error instanceof Error ? error.message : String(error), - }); - } - } - - addCheck({ - key: "codex-config-file", - severity: existsSync(codexConfigPath) ? "ok" : "warn", - message: existsSync(codexConfigPath) - ? "Codex config file found" - : "Codex config file does not exist", - details: codexConfigPath, - }); - - let codexAuthStoreMode: string | undefined; - if (existsSync(codexConfigPath)) { - try { - const configRaw = await fs.readFile(codexConfigPath, "utf-8"); - const match = configRaw.match(/^\s*cli_auth_credentials_store\s*=\s*"([^"]+)"\s*$/m); - if (match?.[1]) { - codexAuthStoreMode = match[1].trim(); - } - } catch (error) { - addCheck({ - key: "codex-auth-store", - severity: "warn", - message: "Unable to read Codex auth-store config", - details: error instanceof Error ? error.message : String(error), - }); - } - } - if (!checks.some((check) => check.key === "codex-auth-store")) { - addCheck({ - key: "codex-auth-store", - severity: codexAuthStoreMode === "file" ? "ok" : "warn", - message: - codexAuthStoreMode === "file" - ? "Codex auth storage is set to file" - : "Codex auth storage is not explicitly set to file", - details: codexAuthStoreMode ? `mode=${codexAuthStoreMode}` : "mode=unset", - }); - } - - const codexCliState = await loadCodexCliState({ forceRefresh: true }); - addCheck({ - key: "codex-cli-state", - severity: codexCliState ? "ok" : "warn", - message: codexCliState - ? "Codex CLI state loaded" - : "Codex CLI state unavailable", - details: codexCliState?.path, - }); - - const storage = await loadAccounts(); - let fixChanged = false; - let fixActions: DoctorFixAction[] = []; - if (options.fix && storage && storage.accounts.length > 0) { - const fixed = applyDoctorFixes(storage); - fixChanged = fixed.changed; - fixActions = fixed.actions; - if (fixChanged && !options.dryRun) { - await saveAccounts(storage); - } - addCheck({ - key: "auto-fix", - severity: fixChanged ? "warn" : "ok", - message: fixChanged - ? options.dryRun - ? `Prepared ${fixActions.length} fix(es) (dry-run)` - : `Applied ${fixActions.length} fix(es)` - : "No safe auto-fixes needed", - }); - } - if (!storage || storage.accounts.length === 0) { - addCheck({ - key: "accounts", - severity: "warn", - message: "No accounts configured", - }); - } else { - addCheck({ - key: "accounts", - severity: "ok", - message: `Loaded ${storage.accounts.length} account(s)`, - }); - - const activeIndex = resolveActiveIndex(storage, "codex"); - const activeExists = activeIndex >= 0 && activeIndex < storage.accounts.length; - addCheck({ - key: "active-index", - severity: activeExists ? "ok" : "error", - message: activeExists - ? `Active index is valid (${activeIndex + 1})` - : "Active index is out of range", - }); - - const disabledCount = storage.accounts.filter((a) => a.enabled === false).length; - addCheck({ - key: "enabled-accounts", - severity: disabledCount >= storage.accounts.length ? "error" : "ok", - message: - disabledCount >= storage.accounts.length - ? "All accounts are disabled" - : `${storage.accounts.length - disabledCount} enabled / ${disabledCount} disabled`, - }); - - const seenRefreshTokens = new Set(); - let duplicateTokenCount = 0; - for (const account of storage.accounts) { - const token = getDoctorRefreshTokenKey(account.refreshToken); - if (!token) continue; - if (seenRefreshTokens.has(token)) { - duplicateTokenCount += 1; - } else { - seenRefreshTokens.add(token); - } - } - addCheck({ - key: "duplicate-refresh-token", - severity: duplicateTokenCount > 0 ? "warn" : "ok", - message: - duplicateTokenCount > 0 - ? `Detected ${duplicateTokenCount} duplicate refresh token entr${duplicateTokenCount === 1 ? "y" : "ies"}` - : "No duplicate refresh tokens detected", - }); - - const seenEmails = new Set(); - let duplicateEmailCount = 0; - let placeholderEmailCount = 0; - let likelyInvalidRefreshTokenCount = 0; - for (const account of storage.accounts) { - const email = sanitizeEmail(account.email); - if (!email) continue; - if (seenEmails.has(email)) duplicateEmailCount += 1; - seenEmails.add(email); - if (hasPlaceholderEmail(email)) placeholderEmailCount += 1; - if (hasLikelyInvalidRefreshToken(account.refreshToken)) { - likelyInvalidRefreshTokenCount += 1; - } - } - addCheck({ - key: "duplicate-email", - severity: duplicateEmailCount > 0 ? "warn" : "ok", - message: - duplicateEmailCount > 0 - ? `Detected ${duplicateEmailCount} duplicate email entr${duplicateEmailCount === 1 ? "y" : "ies"}` - : "No duplicate emails detected", - }); - addCheck({ - key: "placeholder-email", - severity: placeholderEmailCount > 0 ? "warn" : "ok", - message: - placeholderEmailCount > 0 - ? `${placeholderEmailCount} account(s) appear to be placeholder/demo entries` - : "No placeholder emails detected", - }); - addCheck({ - key: "refresh-token-shape", - severity: likelyInvalidRefreshTokenCount > 0 ? "warn" : "ok", - message: - likelyInvalidRefreshTokenCount > 0 - ? `${likelyInvalidRefreshTokenCount} account(s) have likely invalid refresh token format` - : "Refresh token format looks normal", - }); - - const now = Date.now(); - const forecastResults = evaluateForecastAccounts( - storage.accounts.map((account, index) => ({ - index, - account, - isCurrent: index === activeIndex, - now, - })), - ); - const recommendation = recommendForecastAccount(forecastResults); - if (recommendation.recommendedIndex !== null && recommendation.recommendedIndex !== activeIndex) { - addCheck({ - key: "recommended-switch", - severity: "warn", - message: `A healthier account is available: switch to ${recommendation.recommendedIndex + 1}`, - details: recommendation.reason, - }); - } else { - addCheck({ - key: "recommended-switch", - severity: "ok", - message: "Current account aligns with forecast recommendation", - }); - } - - if (activeExists) { - const activeAccount = storage.accounts[activeIndex]; - const managerActiveEmail = sanitizeEmail(activeAccount?.email); - const managerActiveAccountId = activeAccount?.accountId; - const codexActiveEmail = sanitizeEmail(codexCliState?.activeEmail) ?? codexAuthEmail; - const codexActiveAccountId = codexCliState?.activeAccountId ?? codexAuthAccountId; - const isEmailMismatch = - !!managerActiveEmail && - !!codexActiveEmail && - managerActiveEmail !== codexActiveEmail; - const isAccountIdMismatch = - !!managerActiveAccountId && - !!codexActiveAccountId && - managerActiveAccountId !== codexActiveAccountId; - - addCheck({ - key: "active-selection-sync", - severity: isEmailMismatch || isAccountIdMismatch ? "warn" : "ok", - message: - isEmailMismatch || isAccountIdMismatch - ? "Manager active account and Codex active account are not aligned" - : "Manager active account and Codex active account are aligned", - details: `manager=${managerActiveEmail ?? managerActiveAccountId ?? "unknown"} | codex=${codexActiveEmail ?? codexActiveAccountId ?? "unknown"}`, - }); - - if (options.fix && activeAccount) { - let syncAccessToken = activeAccount.accessToken; - let syncRefreshToken = activeAccount.refreshToken; - let syncExpiresAt = activeAccount.expiresAt; - let syncIdToken: string | undefined; - let storageChangedFromDoctorSync = false; - - if (!hasUsableAccessToken(activeAccount, now)) { - if (options.dryRun) { - fixActions.push({ - key: "doctor-refresh", - message: `Prepared active-account token refresh for account ${activeIndex + 1} (dry-run)`, - }); - } else { - const refreshResult = await queuedRefresh(activeAccount.refreshToken); - if (refreshResult.type === "success") { - const refreshedEmail = sanitizeEmail( - extractAccountEmail(refreshResult.access, refreshResult.idToken), - ); - const refreshedAccountId = extractAccountId(refreshResult.access); - activeAccount.accessToken = refreshResult.access; - activeAccount.refreshToken = refreshResult.refresh; - activeAccount.expiresAt = refreshResult.expires; - if (refreshedEmail) activeAccount.email = refreshedEmail; - applyTokenAccountIdentity(activeAccount, refreshedAccountId); - syncAccessToken = refreshResult.access; - syncRefreshToken = refreshResult.refresh; - syncExpiresAt = refreshResult.expires; - syncIdToken = refreshResult.idToken; - storageChangedFromDoctorSync = true; - fixActions.push({ - key: "doctor-refresh", - message: `Refreshed active account tokens for account ${activeIndex + 1}`, - }); - } else { - addCheck({ - key: "doctor-refresh", - severity: "warn", - message: "Unable to refresh active account before Codex sync", - details: normalizeFailureDetail(refreshResult.message, refreshResult.reason), - }); - } - } - } - - if (storageChangedFromDoctorSync) { - fixChanged = true; - if (!options.dryRun) { - await saveAccounts(storage); - } - } - - if (!options.dryRun) { - const synced = await setCodexCliActiveSelection({ - accountId: activeAccount.accountId, - email: activeAccount.email, - accessToken: syncAccessToken, - refreshToken: syncRefreshToken, - expiresAt: syncExpiresAt, - ...(syncIdToken ? { idToken: syncIdToken } : {}), - }); - if (synced) { - fixChanged = true; - fixActions.push({ - key: "codex-active-sync", - message: "Synced manager active account into Codex auth state", - }); - } else { - addCheck({ - key: "codex-active-sync", - severity: "warn", - message: "Failed to sync manager active account into Codex auth state", - }); - } - } else { - fixActions.push({ - key: "codex-active-sync", - message: "Prepared Codex active-account sync (dry-run)", - }); - } - } - } - } - - const summary = checks.reduce( - (acc, check) => { - acc[check.severity] += 1; - return acc; - }, - { ok: 0, warn: 0, error: 0 }, - ); - - if (options.json) { - console.log( - JSON.stringify( - { - command: "doctor", - storagePath, - summary, - checks, - fix: { - enabled: options.fix, - dryRun: options.dryRun, - changed: fixChanged, - actions: fixActions, - }, - }, - null, - 2, - ), - ); - return summary.error > 0 ? 1 : 0; - } - - console.log("Doctor diagnostics"); - console.log(`Storage: ${storagePath}`); - console.log(`Summary: ${summary.ok} ok, ${summary.warn} warnings, ${summary.error} errors`); - console.log(""); - for (const check of checks) { - const marker = check.severity === "ok" ? "✓" : check.severity === "warn" ? "!" : "✗"; - console.log(`${marker} ${check.key}: ${check.message}`); - if (check.details) { - console.log(` ${check.details}`); - } - } - if (options.fix) { - console.log(""); - if (fixActions.length > 0) { - console.log(`Auto-fix actions (${options.dryRun ? "dry-run" : "applied"}):`); - for (const action of fixActions) { - console.log(` - ${action.message}`); - } - } else { - console.log("Auto-fix actions: none"); - } - } - - return summary.error > 0 ? 1 : 0; -} - -async function clearAccountsAndReset(): Promise { - await clearAccounts(); -} - -async function handleManageAction( - storage: AccountStorageV3, - menuResult: Awaited>, -): Promise { - if (typeof menuResult.switchAccountIndex === "number") { - const index = menuResult.switchAccountIndex; - await runSwitch([String(index + 1)]); - return; - } - - if (typeof menuResult.deleteAccountIndex === "number") { - const idx = menuResult.deleteAccountIndex; - if (idx >= 0 && idx < storage.accounts.length) { - storage.accounts.splice(idx, 1); - storage.activeIndex = 0; - storage.activeIndexByFamily = {}; - for (const family of MODEL_FAMILIES) { - storage.activeIndexByFamily[family] = 0; - } - await saveAccounts(storage); - console.log(`Deleted account ${idx + 1}.`); - } - return; - } - - if (typeof menuResult.toggleAccountIndex === "number") { - const idx = menuResult.toggleAccountIndex; - const account = storage.accounts[idx]; - if (account) { - account.enabled = account.enabled === false; - await saveAccounts(storage); - console.log( - `${account.enabled === false ? "Disabled" : "Enabled"} account ${idx + 1}.`, - ); - } - return; - } - - if (typeof menuResult.refreshAccountIndex === "number") { - const idx = menuResult.refreshAccountIndex; - const existing = storage.accounts[idx]; - if (!existing) return; - - const signInMode = await promptOAuthSignInMode(null); - if (signInMode === "cancel") { - console.log(stylePromptText(UI_COPY.oauth.cancelledBackToMenu, "muted")); - return; - } - if (signInMode !== "browser" && signInMode !== "manual") { - return; - } - - const tokenResult = await runOAuthFlow(true, signInMode); - if (tokenResult.type !== "success") { - console.error(`Refresh failed: ${tokenResult.message ?? tokenResult.reason ?? "unknown error"}`); - return; - } - - const resolved = resolveAccountSelection(tokenResult); - await persistAccountPool([resolved], false); - await syncSelectionToCodex(resolved); - console.log(`Refreshed account ${idx + 1}.`); - } -} - -async function runAuthLogin(args: string[]): Promise { - return runAuthLoginCommand(args, authLoginCommandDeps); -} - -async function runSwitch(args: string[]): Promise { - return runSwitchCommand(args, authCommandHelpers); -} - -async function runBest(args: string[]): Promise { - return runBestCommand(args, authCommandHelpers); -} - -async function runForecast(args: string[]): Promise { - return runForecastCommand(args, forecastReportCommandDeps); -} - -async function runReport(args: string[]): Promise { - return runReportCommand(args, forecastReportCommandDeps); -} - -export async function autoSyncActiveAccountToCodex(): Promise { - setStoragePath(null); - const storage = await loadAccounts(); - if (!storage || storage.accounts.length === 0) { - return false; - } - - const activeIndex = resolveActiveIndex(storage, "codex"); - if (activeIndex < 0 || activeIndex >= storage.accounts.length) { - return false; - } - - const account = storage.accounts[activeIndex]; - if (!account) { - return false; - } - - const now = Date.now(); - let syncAccessToken = account.accessToken; - let syncRefreshToken = account.refreshToken; - let syncExpiresAt = account.expiresAt; - let syncIdToken: string | undefined; - let changed = false; - - if (!hasUsableAccessToken(account, now)) { - const refreshResult = await queuedRefresh(account.refreshToken); - if (refreshResult.type === "success") { - const tokenAccountId = extractAccountId(refreshResult.access); - const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)); - if (account.refreshToken !== refreshResult.refresh) { - account.refreshToken = refreshResult.refresh; - changed = true; - } - if (account.accessToken !== refreshResult.access) { - account.accessToken = refreshResult.access; - changed = true; - } - if (account.expiresAt !== refreshResult.expires) { - account.expiresAt = refreshResult.expires; - changed = true; - } - if (nextEmail && nextEmail !== account.email) { - account.email = nextEmail; - changed = true; - } - if (applyTokenAccountIdentity(account, tokenAccountId)) { - changed = true; - } - syncAccessToken = refreshResult.access; - syncRefreshToken = refreshResult.refresh; - syncExpiresAt = refreshResult.expires; - syncIdToken = refreshResult.idToken; - } - } - - if (changed) { - await saveAccounts(storage); - } - - return setCodexCliActiveSelection({ - accountId: account.accountId, - email: account.email, - accessToken: syncAccessToken, - refreshToken: syncRefreshToken, - expiresAt: syncExpiresAt, - ...(syncIdToken ? { idToken: syncIdToken } : {}), - }); + return setCodexCliActiveSelection({ + accountId: account.accountId, + email: account.email, + accessToken: syncAccessToken, + refreshToken: syncRefreshToken, + expiresAt: syncExpiresAt, + ...(syncIdToken ? { idToken: syncIdToken } : {}), + }); } const authCommandHelpers = { @@ -3821,6 +2254,23 @@ const forecastReportCommandDeps = { formatRateLimitEntry, }; +const repairCommandDeps = { + stylePromptText, + styleAccountDetailText, + formatResultSummary, + resolveActiveIndex, + hasUsableAccessToken, + hasLikelyInvalidRefreshToken, + normalizeFailureDetail, + buildQuotaEmailFallbackState, + updateQuotaCacheForAccount, + cloneQuotaCacheData, + pruneUnsafeQuotaEmailCacheEntry, + formatCompactQuotaSnapshot, + resolveStoredAccountIdentity, + applyTokenAccountIdentity, +}; + export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { const startupDisplaySettings = await loadDashboardDisplaySettings(); applyUiThemeFromDashboardSettings(startupDisplaySettings); diff --git a/lib/codex-manager/repair-commands.ts b/lib/codex-manager/repair-commands.ts new file mode 100644 index 00000000..e17ca05e --- /dev/null +++ b/lib/codex-manager/repair-commands.ts @@ -0,0 +1,1751 @@ +import { existsSync, promises as fs } from "node:fs"; +import { ACCOUNT_LIMITS } from "../constants.js"; +import { DEFAULT_DASHBOARD_DISPLAY_SETTINGS } from "../dashboard-settings.js"; +import { + evaluateForecastAccounts, + isHardRefreshFailure, + recommendForecastAccount, +} from "../forecast.js"; +import { + extractAccountEmail, + extractAccountId, + formatAccountLabel, + sanitizeEmail, +} from "../accounts.js"; +import { loadQuotaCache, saveQuotaCache, type QuotaCacheData } from "../quota-cache.js"; +import { fetchCodexQuotaSnapshot } from "../quota-probe.js"; +import { queuedRefresh } from "../refresh-queue.js"; +import { + findMatchingAccountIndex, + getStoragePath, + loadAccounts, + loadFlaggedAccounts, + saveAccounts, + saveFlaggedAccounts, + setStoragePath, + withAccountAndFlaggedStorageTransaction, + type AccountMetadataV3, + type AccountStorageV3, + type FlaggedAccountMetadataV1, +} from "../storage.js"; +import { + getCodexCliAuthPath, + getCodexCliConfigPath, + loadCodexCliState, +} from "../codex-cli/state.js"; +import { setCodexCliActiveSelection } from "../codex-cli/writer.js"; +import { MODEL_FAMILIES, type ModelFamily } from "../prompts/codex.js"; +import type { AccountIdSource, TokenFailure, TokenResult } from "../types.js"; + +type TokenSuccess = Extract; +type PromptTone = "accent" | "success" | "warning" | "danger" | "muted"; + +type QuotaEmailFallbackState = { + matchingCount: number; + distinctAccountIds: Set; +}; + +type QuotaCacheAccountRef = Pick & { + email?: string; +}; + +type ParsedArgsResult = { ok: true; options: T } | { ok: false; message: string }; + +type AccountIdentityResolution = { + accountId?: string; + accountIdSource?: AccountIdSource; +}; + +export interface FixCliOptions { + dryRun: boolean; + json: boolean; + live: boolean; + model: string; +} + +export interface VerifyFlaggedCliOptions { + dryRun: boolean; + json: boolean; + restore: boolean; +} + +export interface DoctorCliOptions { + json: boolean; + fix: boolean; + dryRun: boolean; +} + +export interface RepairCommandDeps { + stylePromptText: (text: string, tone: PromptTone) => string; + styleAccountDetailText: (detail: string, fallbackTone?: PromptTone) => string; + formatResultSummary: ( + segments: ReadonlyArray<{ text: string; tone: PromptTone }>, + ) => string; + resolveActiveIndex: ( + storage: AccountStorageV3, + family?: ModelFamily, + ) => number; + hasUsableAccessToken: ( + account: AccountMetadataV3, + now: number, + ) => boolean; + hasLikelyInvalidRefreshToken: (refreshToken: string | undefined) => boolean; + normalizeFailureDetail: ( + message: string | undefined, + reason: string | undefined, + ) => string; + buildQuotaEmailFallbackState: ( + accounts: readonly QuotaCacheAccountRef[], + ) => ReadonlyMap; + updateQuotaCacheForAccount: ( + cache: QuotaCacheData, + account: QuotaCacheAccountRef, + snapshot: Awaited>, + accounts: readonly QuotaCacheAccountRef[], + emailFallbackState?: ReadonlyMap, + ) => boolean; + cloneQuotaCacheData: (cache: QuotaCacheData) => QuotaCacheData; + pruneUnsafeQuotaEmailCacheEntry: ( + cache: QuotaCacheData, + previousEmail: string | undefined, + accounts: readonly QuotaCacheAccountRef[], + emailFallbackState: ReadonlyMap, + ) => boolean; + formatCompactQuotaSnapshot: ( + snapshot: Awaited>, + ) => string; + resolveStoredAccountIdentity: ( + storedAccountId: string | undefined, + storedAccountIdSource: AccountIdSource | undefined, + refreshedAccountId: string | undefined, + ) => AccountIdentityResolution; + applyTokenAccountIdentity: ( + account: AccountMetadataV3, + refreshedAccountId: string | undefined, + ) => boolean; +} + +export function printFixUsage(): void { + console.log( + [ + "Usage:", + " codex auth fix [--dry-run] [--json] [--live] [--model ]", + "", + "Options:", + " --dry-run, -n Preview changes without writing storage", + " --json, -j Print machine-readable JSON output", + " --live, -l Run live session probe before deciding health", + " --model, -m Probe model for live mode (default: gpt-5-codex)", + "", + "Behavior:", + " - Refreshes tokens for enabled accounts", + " - Disables hard-failed accounts (never deletes)", + " - Recommends a better current account when needed", + ].join("\n"), + ); +} + +export function printVerifyFlaggedUsage(): void { + console.log( + [ + "Usage:", + " codex auth verify-flagged [--dry-run] [--json] [--no-restore]", + "", + "Options:", + " --dry-run, -n Preview changes without writing storage", + " --json, -j Print machine-readable JSON output", + " --no-restore Check flagged accounts without restoring healthy ones", + "", + "Behavior:", + " - Refresh-checks accounts from flagged storage", + " - Restores healthy accounts back to active storage by default", + ].join("\n"), + ); +} + +export function printDoctorUsage(): void { + console.log( + [ + "Usage:", + " codex auth doctor [--json] [--fix] [--dry-run]", + "", + "Options:", + " --json, -j Print machine-readable JSON diagnostics", + " --fix Apply safe auto-fixes to storage", + " --dry-run, -n Preview --fix changes without writing storage", + "", + "Behavior:", + " - Validates account storage readability", + " - Checks active index consistency and account duplication", + " - Flags placeholder/demo accounts and disabled-all scenarios", + ].join("\n"), + ); +} + +export function parseFixArgs(args: string[]): ParsedArgsResult { + const options: FixCliOptions = { + dryRun: false, + json: false, + live: false, + model: "gpt-5-codex", + }; + + for (let i = 0; i < args.length; i += 1) { + const argValue = args[i]; + if (typeof argValue !== "string") continue; + if (argValue === "--dry-run" || argValue === "-n") { + options.dryRun = true; + continue; + } + if (argValue === "--json" || argValue === "-j") { + options.json = true; + continue; + } + if (argValue === "--live" || argValue === "-l") { + options.live = true; + continue; + } + if (argValue === "--model" || argValue === "-m") { + const value = args[i + 1]; + if (!value) { + return { ok: false, message: "Missing value for --model" }; + } + options.model = value; + i += 1; + continue; + } + if (argValue.startsWith("--model=")) { + const value = argValue.slice("--model=".length).trim(); + if (!value) { + return { ok: false, message: "Missing value for --model" }; + } + options.model = value; + continue; + } + return { ok: false, message: `Unknown option: ${argValue}` }; + } + + return { ok: true, options }; +} + +export function parseVerifyFlaggedArgs( + args: string[], +): ParsedArgsResult { + const options: VerifyFlaggedCliOptions = { + dryRun: false, + json: false, + restore: true, + }; + + for (const arg of args) { + if (arg === "--dry-run" || arg === "-n") { + options.dryRun = true; + continue; + } + if (arg === "--json" || arg === "-j") { + options.json = true; + continue; + } + if (arg === "--no-restore") { + options.restore = false; + continue; + } + return { ok: false, message: `Unknown option: ${arg}` }; + } + + return { ok: true, options }; +} + +export function parseDoctorArgs( + args: string[], +): ParsedArgsResult { + const options: DoctorCliOptions = { json: false, fix: false, dryRun: false }; + for (const arg of args) { + if (arg === "--json" || arg === "-j") { + options.json = true; + continue; + } + if (arg === "--fix") { + options.fix = true; + continue; + } + if (arg === "--dry-run" || arg === "-n") { + options.dryRun = true; + continue; + } + return { ok: false, message: `Unknown option: ${arg}` }; + } + if (options.dryRun && !options.fix) { + return { ok: false, message: "--dry-run requires --fix" }; + } + return { ok: true, options }; +} + +type FixOutcome = + | "healthy" + | "disabled-hard-failure" + | "warning-soft-failure" + | "already-disabled"; + +interface FixAccountReport { + index: number; + label: string; + outcome: FixOutcome; + message: string; +} + +function summarizeFixReports( + reports: FixAccountReport[], +): { + healthy: number; + disabled: number; + warnings: number; + skipped: number; +} { + let healthy = 0; + let disabled = 0; + let warnings = 0; + let skipped = 0; + for (const report of reports) { + if (report.outcome === "healthy") healthy += 1; + else if (report.outcome === "disabled-hard-failure") disabled += 1; + else if (report.outcome === "warning-soft-failure") warnings += 1; + else skipped += 1; + } + return { healthy, disabled, warnings, skipped }; +} + +interface VerifyFlaggedReport { + index: number; + label: string; + outcome: "restored" | "healthy-flagged" | "still-flagged" | "restore-skipped"; + message: string; +} + +function createEmptyAccountStorage(): AccountStorageV3 { + const activeIndexByFamily: Partial> = {}; + for (const family of MODEL_FAMILIES) { + activeIndexByFamily[family] = 0; + } + return { + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily, + }; +} + +function findExistingAccountIndexForFlagged( + storage: AccountStorageV3, + flagged: FlaggedAccountMetadataV1, + nextRefreshToken: string, + nextAccountId: string | undefined, + nextEmail: string | undefined, +): number { + const flaggedEmail = sanitizeEmail(flagged.email); + const candidateAccountId = nextAccountId ?? flagged.accountId; + const candidateEmail = sanitizeEmail(nextEmail) ?? flaggedEmail; + const nextMatchIndex = findMatchingAccountIndex(storage.accounts, { + accountId: candidateAccountId, + email: candidateEmail, + refreshToken: nextRefreshToken, + }, { + allowUniqueAccountIdFallbackWithoutEmail: true, + }); + if (nextMatchIndex !== undefined) { + return nextMatchIndex; + } + + const flaggedMatchIndex = findMatchingAccountIndex(storage.accounts, { + accountId: candidateAccountId, + email: candidateEmail, + refreshToken: flagged.refreshToken, + }, { + allowUniqueAccountIdFallbackWithoutEmail: true, + }); + return flaggedMatchIndex ?? -1; +} + +function upsertRecoveredFlaggedAccount( + storage: AccountStorageV3, + flagged: FlaggedAccountMetadataV1, + refreshResult: TokenSuccess, + now: number, + deps: Pick, +): { restored: boolean; changed: boolean; message: string } { + const nextEmail = + sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)) + ?? flagged.email; + const tokenAccountId = extractAccountId(refreshResult.access); + const { accountId: nextAccountId, accountIdSource: nextAccountIdSource } = + deps.resolveStoredAccountIdentity( + flagged.accountId, + flagged.accountIdSource, + tokenAccountId, + ); + const existingIndex = findExistingAccountIndexForFlagged( + storage, + flagged, + refreshResult.refresh, + nextAccountId, + nextEmail, + ); + + if (existingIndex >= 0) { + const existing = storage.accounts[existingIndex]; + if (!existing) { + return { restored: false, changed: false, message: "existing account entry is missing" }; + } + let changed = false; + if (existing.refreshToken !== refreshResult.refresh) { + existing.refreshToken = refreshResult.refresh; + changed = true; + } + if (existing.accessToken !== refreshResult.access) { + existing.accessToken = refreshResult.access; + changed = true; + } + if (existing.expiresAt !== refreshResult.expires) { + existing.expiresAt = refreshResult.expires; + changed = true; + } + if (nextEmail && nextEmail !== existing.email) { + existing.email = nextEmail; + changed = true; + } + if ( + nextAccountId !== undefined + && ( + nextAccountId !== existing.accountId + || nextAccountIdSource !== existing.accountIdSource + ) + ) { + existing.accountId = nextAccountId; + existing.accountIdSource = nextAccountIdSource; + changed = true; + } + if (existing.enabled === false) { + existing.enabled = true; + changed = true; + } + if (existing.accountLabel !== flagged.accountLabel && flagged.accountLabel) { + existing.accountLabel = flagged.accountLabel; + changed = true; + } + existing.lastUsed = now; + return { + restored: true, + changed, + message: `restored into existing account ${existingIndex + 1}`, + }; + } + + if (storage.accounts.length >= ACCOUNT_LIMITS.MAX_ACCOUNTS) { + return { + restored: false, + changed: false, + message: `cannot restore (max ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts reached)`, + }; + } + + storage.accounts.push({ + refreshToken: refreshResult.refresh, + accessToken: refreshResult.access, + expiresAt: refreshResult.expires, + accountId: nextAccountId, + accountIdSource: nextAccountIdSource, + accountLabel: flagged.accountLabel, + email: nextEmail, + addedAt: flagged.addedAt ?? now, + lastUsed: now, + enabled: true, + }); + return { + restored: true, + changed: true, + message: `restored as account ${storage.accounts.length}`, + }; +} + +type DoctorSeverity = "ok" | "warn" | "error"; + +interface DoctorCheck { + key: string; + severity: DoctorSeverity; + message: string; + details?: string; +} + +interface DoctorFixAction { + key: string; + message: string; +} + +function hasPlaceholderEmail(value: string | undefined): boolean { + if (!value) return false; + const email = value.trim().toLowerCase(); + if (!email) return false; + return ( + email.endsWith("@example.com") + || email.includes("account1@example.com") + || email.includes("account2@example.com") + || email.includes("account3@example.com") + ); +} + +function normalizeDoctorIndexes(storage: AccountStorageV3): boolean { + const total = storage.accounts.length; + const nextActive = total === 0 ? 0 : Math.max(0, Math.min(storage.activeIndex, total - 1)); + let changed = false; + if (storage.activeIndex !== nextActive) { + storage.activeIndex = nextActive; + changed = true; + } + storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; + for (const family of MODEL_FAMILIES) { + const raw = storage.activeIndexByFamily[family]; + const fallback = storage.activeIndex; + const candidate = typeof raw === "number" && Number.isFinite(raw) ? raw : fallback; + const clamped = total === 0 ? 0 : Math.max(0, Math.min(candidate, total - 1)); + if (storage.activeIndexByFamily[family] !== clamped) { + storage.activeIndexByFamily[family] = clamped; + changed = true; + } + } + return changed; +} + +function getDoctorRefreshTokenKey(refreshToken: unknown): string | undefined { + if (typeof refreshToken !== "string") return undefined; + const trimmed = refreshToken.trim(); + return trimmed || undefined; +} + +function applyDoctorFixes( + storage: AccountStorageV3, + deps: Pick, +): { changed: boolean; actions: DoctorFixAction[] } { + let changed = false; + const actions: DoctorFixAction[] = []; + + if (normalizeDoctorIndexes(storage)) { + changed = true; + actions.push({ + key: "active-index", + message: "Normalized active account indexes", + }); + } + + const seenRefreshTokens = new Map(); + for (let i = 0; i < storage.accounts.length; i += 1) { + const account = storage.accounts[i]; + if (!account) continue; + + const refreshToken = getDoctorRefreshTokenKey(account.refreshToken); + if (!refreshToken) continue; + const existingTokenIndex = seenRefreshTokens.get(refreshToken); + if (typeof existingTokenIndex === "number") { + if (account.enabled !== false) { + account.enabled = false; + changed = true; + actions.push({ + key: "duplicate-refresh-token", + message: `Disabled duplicate token entry on account ${i + 1} (kept account ${existingTokenIndex + 1})`, + }); + } + } else { + seenRefreshTokens.set(refreshToken, i); + } + + const tokenEmail = sanitizeEmail(extractAccountEmail(account.accessToken)); + if (tokenEmail && (!sanitizeEmail(account.email) || hasPlaceholderEmail(account.email))) { + account.email = tokenEmail; + changed = true; + actions.push({ + key: "email-from-token", + message: `Updated account ${i + 1} email from token claims`, + }); + } + + const tokenAccountId = extractAccountId(account.accessToken); + if (!account.accountId && tokenAccountId) { + account.accountId = tokenAccountId; + account.accountIdSource = "token"; + changed = true; + actions.push({ + key: "account-id-from-token", + message: `Filled missing accountId for account ${i + 1}`, + }); + } + } + + const enabledCount = storage.accounts.filter((account) => account.enabled !== false).length; + if (storage.accounts.length > 0 && enabledCount === 0) { + const index = deps.resolveActiveIndex(storage, "codex"); + const candidate = storage.accounts[index] ?? storage.accounts[0]; + if (candidate) { + candidate.enabled = true; + changed = true; + actions.push({ + key: "enabled-accounts", + message: `Re-enabled account ${index + 1} to avoid an all-disabled pool`, + }); + } + } + + if (normalizeDoctorIndexes(storage)) { + changed = true; + } + + return { changed, actions }; +} + +export async function runVerifyFlagged( + args: string[], + deps: RepairCommandDeps, +): Promise { + if (args.includes("--help") || args.includes("-h")) { + printVerifyFlaggedUsage(); + return 0; + } + + const parsedArgs = parseVerifyFlaggedArgs(args); + if (!parsedArgs.ok) { + console.error(parsedArgs.message); + printVerifyFlaggedUsage(); + return 1; + } + const options = parsedArgs.options; + + setStoragePath(null); + const flaggedStorage = await loadFlaggedAccounts(); + if (flaggedStorage.accounts.length === 0) { + if (options.json) { + console.log( + JSON.stringify( + { + command: "verify-flagged", + total: 0, + restored: 0, + healthyFlagged: 0, + stillFlagged: 0, + changed: false, + dryRun: options.dryRun, + restore: options.restore, + reports: [] as VerifyFlaggedReport[], + }, + null, + 2, + ), + ); + return 0; + } + console.log("No flagged accounts to check."); + return 0; + } + + let storageChanged = false; + let flaggedChanged = false; + const reports: VerifyFlaggedReport[] = []; + const nextFlaggedAccounts: FlaggedAccountMetadataV1[] = []; + const now = Date.now(); + const refreshChecks: Array<{ + index: number; + flagged: FlaggedAccountMetadataV1; + label: string; + result: Awaited>; + }> = []; + + for (let i = 0; i < flaggedStorage.accounts.length; i += 1) { + const flagged = flaggedStorage.accounts[i]; + if (!flagged) continue; + refreshChecks.push({ + index: i, + flagged, + label: formatAccountLabel(flagged, i), + result: await queuedRefresh(flagged.refreshToken), + }); + } + + const applyRefreshChecks = (storage: AccountStorageV3): void => { + for (const check of refreshChecks) { + const { index: i, flagged, label, result } = check; + if (result.type === "success") { + if (!options.restore) { + const tokenAccountId = extractAccountId(result.access); + const nextIdentity = deps.resolveStoredAccountIdentity( + flagged.accountId, + flagged.accountIdSource, + tokenAccountId, + ); + const nextFlagged: FlaggedAccountMetadataV1 = { + ...flagged, + refreshToken: result.refresh, + accessToken: result.access, + expiresAt: result.expires, + accountId: nextIdentity.accountId, + accountIdSource: nextIdentity.accountIdSource, + email: + sanitizeEmail(extractAccountEmail(result.access, result.idToken)) + ?? flagged.email, + lastUsed: now, + lastError: undefined, + }; + nextFlaggedAccounts.push(nextFlagged); + if (JSON.stringify(nextFlagged) !== JSON.stringify(flagged)) { + flaggedChanged = true; + } + reports.push({ + index: i, + label, + outcome: "healthy-flagged", + message: "session is healthy (left in flagged list due to --no-restore)", + }); + continue; + } + + const upsertResult = upsertRecoveredFlaggedAccount( + storage, + flagged, + result, + now, + deps, + ); + if (upsertResult.restored) { + storageChanged = storageChanged || upsertResult.changed; + flaggedChanged = true; + reports.push({ + index: i, + label, + outcome: "restored", + message: upsertResult.message, + }); + continue; + } + + const tokenAccountId = extractAccountId(result.access); + const nextIdentity = deps.resolveStoredAccountIdentity( + flagged.accountId, + flagged.accountIdSource, + tokenAccountId, + ); + const updatedFlagged: FlaggedAccountMetadataV1 = { + ...flagged, + refreshToken: result.refresh, + accessToken: result.access, + expiresAt: result.expires, + accountId: nextIdentity.accountId, + accountIdSource: nextIdentity.accountIdSource, + email: + sanitizeEmail(extractAccountEmail(result.access, result.idToken)) + ?? flagged.email, + lastUsed: now, + lastError: upsertResult.message, + }; + nextFlaggedAccounts.push(updatedFlagged); + if (JSON.stringify(updatedFlagged) !== JSON.stringify(flagged)) { + flaggedChanged = true; + } + reports.push({ + index: i, + label, + outcome: "restore-skipped", + message: upsertResult.message, + }); + continue; + } + + const detail = deps.normalizeFailureDetail(result.message, result.reason); + const failedFlagged: FlaggedAccountMetadataV1 = { + ...flagged, + lastError: detail, + }; + nextFlaggedAccounts.push(failedFlagged); + if ((flagged.lastError ?? "") !== detail) { + flaggedChanged = true; + } + reports.push({ + index: i, + label, + outcome: "still-flagged", + message: detail, + }); + } + }; + + if (options.restore) { + if (options.dryRun) { + applyRefreshChecks((await loadAccounts()) ?? createEmptyAccountStorage()); + } else { + await withAccountAndFlaggedStorageTransaction(async (loadedStorage, persist) => { + const nextStorage = loadedStorage + ? structuredClone(loadedStorage) + : createEmptyAccountStorage(); + applyRefreshChecks(nextStorage); + if (!storageChanged) { + return; + } + normalizeDoctorIndexes(nextStorage); + await persist(nextStorage, { + version: 1, + accounts: nextFlaggedAccounts, + }); + }); + } + } else { + applyRefreshChecks(createEmptyAccountStorage()); + } + + const remainingFlagged = nextFlaggedAccounts.length; + const restored = reports.filter((report) => report.outcome === "restored").length; + const healthyFlagged = reports.filter( + (report) => report.outcome === "healthy-flagged", + ).length; + const stillFlagged = reports.filter( + (report) => report.outcome === "still-flagged", + ).length; + const changed = storageChanged || flaggedChanged; + + if (!options.dryRun && flaggedChanged && (!options.restore || !storageChanged)) { + await saveFlaggedAccounts({ + version: 1, + accounts: nextFlaggedAccounts, + }); + } + + if (options.json) { + console.log( + JSON.stringify( + { + command: "verify-flagged", + total: flaggedStorage.accounts.length, + restored, + healthyFlagged, + stillFlagged, + remainingFlagged, + changed, + dryRun: options.dryRun, + restore: options.restore, + reports, + }, + null, + 2, + ), + ); + return 0; + } + + console.log( + deps.stylePromptText( + `Checking ${flaggedStorage.accounts.length} flagged account(s)...`, + "accent", + ), + ); + for (const report of reports) { + const tone: PromptTone = + report.outcome === "restored" + ? "success" + : report.outcome === "healthy-flagged" || report.outcome === "restore-skipped" + ? "warning" + : "danger"; + const marker = + report.outcome === "restored" + ? "✓" + : report.outcome === "healthy-flagged" || report.outcome === "restore-skipped" + ? "!" + : "✗"; + console.log( + `${deps.stylePromptText(marker, tone)} ${deps.stylePromptText(`${report.index + 1}. ${report.label}`, "accent")} ${deps.stylePromptText("|", "muted")} ${deps.styleAccountDetailText(report.message, tone)}`, + ); + } + console.log(""); + console.log( + deps.formatResultSummary([ + { text: `${restored} restored`, tone: restored > 0 ? "success" : "muted" }, + { + text: `${healthyFlagged} healthy (kept flagged)`, + tone: healthyFlagged > 0 ? "warning" : "muted", + }, + { + text: `${stillFlagged} still flagged`, + tone: stillFlagged > 0 ? "danger" : "muted", + }, + ]), + ); + if (options.dryRun) { + console.log(deps.stylePromptText("Preview only: no changes were saved.", "warning")); + } else if (!changed) { + console.log(deps.stylePromptText("No storage changes were needed.", "muted")); + } + + return 0; +} + +export async function runFix( + args: string[], + deps: RepairCommandDeps, +): Promise { + if (args.includes("--help") || args.includes("-h")) { + printFixUsage(); + return 0; + } + + const parsedArgs = parseFixArgs(args); + if (!parsedArgs.ok) { + console.error(parsedArgs.message); + printFixUsage(); + return 1; + } + const options = parsedArgs.options; + const display = DEFAULT_DASHBOARD_DISPLAY_SETTINGS; + const quotaCache = options.live ? await loadQuotaCache() : null; + const workingQuotaCache = quotaCache ? deps.cloneQuotaCacheData(quotaCache) : null; + let quotaCacheChanged = false; + + setStoragePath(null); + const storage = await loadAccounts(); + if (!storage || storage.accounts.length === 0) { + console.log("No accounts configured."); + return 0; + } + let quotaEmailFallbackState = + options.live && quotaCache + ? deps.buildQuotaEmailFallbackState(storage.accounts) + : null; + + const now = Date.now(); + const activeIndex = deps.resolveActiveIndex(storage, "codex"); + let changed = false; + const reports: FixAccountReport[] = []; + const refreshFailures = new Map(); + const hardDisabledIndexes: number[] = []; + + for (let i = 0; i < storage.accounts.length; i += 1) { + const account = storage.accounts[i]; + if (!account) continue; + const label = formatAccountLabel(account, i); + + if (account.enabled === false) { + reports.push({ + index: i, + label, + outcome: "already-disabled", + message: "already disabled", + }); + continue; + } + + if (deps.hasUsableAccessToken(account, now)) { + if (options.live) { + const currentAccessToken = account.accessToken; + const probeAccountId = currentAccessToken + ? account.accountId ?? extractAccountId(currentAccessToken) + : undefined; + if (probeAccountId && currentAccessToken) { + try { + const snapshot = await fetchCodexQuotaSnapshot({ + accountId: probeAccountId, + accessToken: currentAccessToken, + model: options.model, + }); + if (workingQuotaCache) { + quotaCacheChanged = + deps.updateQuotaCacheForAccount( + workingQuotaCache, + account, + snapshot, + storage.accounts, + quotaEmailFallbackState ?? undefined, + ) || quotaCacheChanged; + } + reports.push({ + index: i, + label, + outcome: "healthy", + message: display.showQuotaDetails + ? `live session OK (${deps.formatCompactQuotaSnapshot(snapshot)})` + : "live session OK", + }); + continue; + } catch (error) { + const message = deps.normalizeFailureDetail( + error instanceof Error ? error.message : String(error), + undefined, + ); + reports.push({ + index: i, + label, + outcome: "warning-soft-failure", + message: `live probe failed (${message}), trying refresh fallback`, + }); + } + } + } + + const refreshWarning = deps.hasLikelyInvalidRefreshToken(account.refreshToken) + ? " (refresh token looks stale; re-login recommended)" + : ""; + reports.push({ + index: i, + label, + outcome: "healthy", + message: `access token still valid${refreshWarning}`, + }); + continue; + } + + const refreshResult = await queuedRefresh(account.refreshToken); + if (refreshResult.type === "success") { + const nextEmail = sanitizeEmail( + extractAccountEmail(refreshResult.access, refreshResult.idToken), + ); + const nextAccountId = extractAccountId(refreshResult.access); + const previousEmail = account.email; + let accountChanged = false; + let accountIdentityChanged = false; + + if (account.refreshToken !== refreshResult.refresh) { + account.refreshToken = refreshResult.refresh; + accountChanged = true; + } + if (account.accessToken !== refreshResult.access) { + account.accessToken = refreshResult.access; + accountChanged = true; + } + if (account.expiresAt !== refreshResult.expires) { + account.expiresAt = refreshResult.expires; + accountChanged = true; + } + if (nextEmail && nextEmail !== account.email) { + account.email = nextEmail; + accountChanged = true; + accountIdentityChanged = true; + } + if (deps.applyTokenAccountIdentity(account, nextAccountId)) { + accountChanged = true; + accountIdentityChanged = true; + } + + if (accountChanged) changed = true; + if (accountIdentityChanged && options.live && workingQuotaCache) { + quotaEmailFallbackState = deps.buildQuotaEmailFallbackState(storage.accounts); + quotaCacheChanged = + deps.pruneUnsafeQuotaEmailCacheEntry( + workingQuotaCache, + previousEmail, + storage.accounts, + quotaEmailFallbackState, + ) || quotaCacheChanged; + } + if (options.live) { + const probeAccountId = account.accountId ?? nextAccountId; + if (probeAccountId) { + try { + const snapshot = await fetchCodexQuotaSnapshot({ + accountId: probeAccountId, + accessToken: refreshResult.access, + model: options.model, + }); + if (workingQuotaCache) { + quotaCacheChanged = + deps.updateQuotaCacheForAccount( + workingQuotaCache, + account, + snapshot, + storage.accounts, + quotaEmailFallbackState ?? undefined, + ) || quotaCacheChanged; + } + reports.push({ + index: i, + label, + outcome: "healthy", + message: display.showQuotaDetails + ? `refresh + live probe succeeded (${deps.formatCompactQuotaSnapshot(snapshot)})` + : "refresh + live probe succeeded", + }); + continue; + } catch (error) { + const message = deps.normalizeFailureDetail( + error instanceof Error ? error.message : String(error), + undefined, + ); + reports.push({ + index: i, + label, + outcome: "warning-soft-failure", + message: `refresh succeeded but live probe failed: ${message}`, + }); + continue; + } + } + } + reports.push({ + index: i, + label, + outcome: "healthy", + message: "refresh succeeded", + }); + continue; + } + + const detail = deps.normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ); + refreshFailures.set(i, { + ...refreshResult, + message: detail, + }); + if (isHardRefreshFailure(refreshResult)) { + account.enabled = false; + changed = true; + hardDisabledIndexes.push(i); + reports.push({ + index: i, + label, + outcome: "disabled-hard-failure", + message: detail, + }); + } else { + reports.push({ + index: i, + label, + outcome: "warning-soft-failure", + message: detail, + }); + } + } + + if (hardDisabledIndexes.length > 0) { + const enabledCount = storage.accounts.filter( + (account) => account.enabled !== false, + ).length; + if (enabledCount === 0) { + const fallbackIndex = hardDisabledIndexes.includes(activeIndex) + ? activeIndex + : hardDisabledIndexes[0]; + const fallback = typeof fallbackIndex === "number" + ? storage.accounts[fallbackIndex] + : undefined; + if (fallback && fallback.enabled === false) { + fallback.enabled = true; + changed = true; + const existingReport = reports.find( + (report) => + report.index === fallbackIndex + && report.outcome === "disabled-hard-failure", + ); + if (existingReport) { + existingReport.outcome = "warning-soft-failure"; + existingReport.message = + `${existingReport.message} (kept enabled to avoid lockout; re-login required)`; + } + } + } + } + + const forecastResults = evaluateForecastAccounts( + storage.accounts.map((account, index) => ({ + index, + account, + isCurrent: index === activeIndex, + now, + refreshFailure: refreshFailures.get(index), + })), + ); + const recommendation = recommendForecastAccount(forecastResults); + const reportSummary = summarizeFixReports(reports); + + if (changed && !options.dryRun) { + await saveAccounts(storage); + } + + if (options.json) { + if (workingQuotaCache && quotaCacheChanged) { + await saveQuotaCache(workingQuotaCache); + } + console.log( + JSON.stringify( + { + command: "fix", + dryRun: options.dryRun, + liveProbe: options.live, + model: options.model, + changed, + summary: reportSummary, + recommendation, + recommendedSwitchCommand: + recommendation.recommendedIndex !== null + && recommendation.recommendedIndex !== activeIndex + ? `codex auth switch ${recommendation.recommendedIndex + 1}` + : null, + reports, + }, + null, + 2, + ), + ); + return 0; + } + + console.log( + deps.stylePromptText( + `Auto-fix scan (${options.dryRun ? "preview" : "apply"})`, + "accent", + ), + ); + console.log( + deps.formatResultSummary([ + { text: `${reportSummary.healthy} working`, tone: "success" }, + { + text: `${reportSummary.disabled} disabled`, + tone: reportSummary.disabled > 0 ? "danger" : "muted", + }, + { + text: `${reportSummary.warnings} warning${reportSummary.warnings === 1 ? "" : "s"}`, + tone: reportSummary.warnings > 0 ? "warning" : "muted", + }, + { text: `${reportSummary.skipped} already disabled`, tone: "muted" }, + ]), + ); + if (display.showPerAccountRows) { + console.log(""); + for (const report of reports) { + const prefix = + report.outcome === "healthy" + ? "✓" + : report.outcome === "disabled-hard-failure" + ? "✗" + : report.outcome === "warning-soft-failure" + ? "!" + : "-"; + const tone: PromptTone = + report.outcome === "healthy" + ? "success" + : report.outcome === "disabled-hard-failure" + ? "danger" + : report.outcome === "warning-soft-failure" + ? "warning" + : "muted"; + console.log( + `${deps.stylePromptText(prefix, tone)} ${deps.stylePromptText(`${report.index + 1}. ${report.label}`, "accent")} ${deps.stylePromptText("|", "muted")} ${deps.styleAccountDetailText(report.message, tone === "success" ? "muted" : tone)}`, + ); + } + } else { + console.log(""); + console.log( + deps.stylePromptText( + "Per-account lines are hidden in dashboard settings.", + "muted", + ), + ); + } + + if (display.showRecommendations) { + console.log(""); + if (recommendation.recommendedIndex !== null) { + const target = recommendation.recommendedIndex + 1; + console.log( + `${deps.stylePromptText("Best next account:", "accent")} ${deps.stylePromptText(String(target), "success")}`, + ); + console.log( + `${deps.stylePromptText("Why:", "accent")} ${deps.stylePromptText(recommendation.reason, "muted")}`, + ); + if (recommendation.recommendedIndex !== activeIndex) { + console.log( + `${deps.stylePromptText("Switch now with:", "accent")} codex auth switch ${target}`, + ); + } + } else { + console.log( + `${deps.stylePromptText("Note:", "accent")} ${deps.stylePromptText(recommendation.reason, "muted")}`, + ); + } + } + if (workingQuotaCache && quotaCacheChanged) { + await saveQuotaCache(workingQuotaCache); + } + + if (changed && options.dryRun) { + console.log(`\n${deps.stylePromptText("Preview only: no changes were saved.", "warning")}`); + } else if (changed) { + console.log(`\n${deps.stylePromptText("Saved updates.", "success")}`); + } else { + console.log(`\n${deps.stylePromptText("No changes were needed.", "muted")}`); + } + + return 0; +} + +export async function runDoctor( + args: string[], + deps: RepairCommandDeps, +): Promise { + if (args.includes("--help") || args.includes("-h")) { + printDoctorUsage(); + return 0; + } + + const parsedArgs = parseDoctorArgs(args); + if (!parsedArgs.ok) { + console.error(parsedArgs.message); + printDoctorUsage(); + return 1; + } + const options = parsedArgs.options; + + setStoragePath(null); + const storagePath = getStoragePath(); + const checks: DoctorCheck[] = []; + const addCheck = (check: DoctorCheck): void => { + checks.push(check); + }; + + addCheck({ + key: "storage-file", + severity: existsSync(storagePath) ? "ok" : "warn", + message: existsSync(storagePath) + ? "Account storage file found" + : "Account storage file does not exist yet (first login pending)", + details: storagePath, + }); + + if (existsSync(storagePath)) { + try { + const stat = await fs.stat(storagePath); + addCheck({ + key: "storage-readable", + severity: stat.size > 0 ? "ok" : "warn", + message: stat.size > 0 ? "Storage file is readable" : "Storage file is empty", + details: `${stat.size} bytes`, + }); + } catch (error) { + addCheck({ + key: "storage-readable", + severity: "error", + message: "Unable to read storage file metadata", + details: error instanceof Error ? error.message : String(error), + }); + } + } + + const codexAuthPath = getCodexCliAuthPath(); + const codexConfigPath = getCodexCliConfigPath(); + let codexAuthEmail: string | undefined; + let codexAuthAccountId: string | undefined; + + addCheck({ + key: "codex-auth-file", + severity: existsSync(codexAuthPath) ? "ok" : "warn", + message: existsSync(codexAuthPath) + ? "Codex auth file found" + : "Codex auth file does not exist", + details: codexAuthPath, + }); + + if (existsSync(codexAuthPath)) { + try { + const raw = await fs.readFile(codexAuthPath, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === "object") { + const payload = parsed as Record; + const tokens = payload.tokens && typeof payload.tokens === "object" + ? payload.tokens as Record + : null; + const accessToken = tokens && typeof tokens.access_token === "string" + ? tokens.access_token + : undefined; + const idToken = tokens && typeof tokens.id_token === "string" + ? tokens.id_token + : undefined; + const accountIdFromFile = + tokens && typeof tokens.account_id === "string" + ? tokens.account_id + : undefined; + const emailFromFile = + typeof payload.email === "string" ? payload.email : undefined; + codexAuthEmail = sanitizeEmail( + emailFromFile ?? extractAccountEmail(accessToken, idToken), + ); + codexAuthAccountId = accountIdFromFile ?? extractAccountId(accessToken); + } + addCheck({ + key: "codex-auth-readable", + severity: "ok", + message: "Codex auth file is readable", + details: + codexAuthEmail || codexAuthAccountId + ? `email=${codexAuthEmail ?? "unknown"}, accountId=${codexAuthAccountId ?? "unknown"}` + : undefined, + }); + } catch (error) { + addCheck({ + key: "codex-auth-readable", + severity: "error", + message: "Unable to read Codex auth file", + details: error instanceof Error ? error.message : String(error), + }); + } + } + + addCheck({ + key: "codex-config-file", + severity: existsSync(codexConfigPath) ? "ok" : "warn", + message: existsSync(codexConfigPath) + ? "Codex config file found" + : "Codex config file does not exist", + details: codexConfigPath, + }); + + let codexAuthStoreMode: string | undefined; + if (existsSync(codexConfigPath)) { + try { + const configRaw = await fs.readFile(codexConfigPath, "utf-8"); + const match = configRaw.match(/^\s*cli_auth_credentials_store\s*=\s*"([^"]+)"\s*$/m); + if (match?.[1]) { + codexAuthStoreMode = match[1].trim(); + } + } catch (error) { + addCheck({ + key: "codex-auth-store", + severity: "warn", + message: "Unable to read Codex auth-store config", + details: error instanceof Error ? error.message : String(error), + }); + } + } + if (!checks.some((check) => check.key === "codex-auth-store")) { + addCheck({ + key: "codex-auth-store", + severity: codexAuthStoreMode === "file" ? "ok" : "warn", + message: + codexAuthStoreMode === "file" + ? "Codex auth storage is set to file" + : "Codex auth storage is not explicitly set to file", + details: codexAuthStoreMode ? `mode=${codexAuthStoreMode}` : "mode=unset", + }); + } + + const codexCliState = await loadCodexCliState({ forceRefresh: true }); + addCheck({ + key: "codex-cli-state", + severity: codexCliState ? "ok" : "warn", + message: codexCliState + ? "Codex CLI state loaded" + : "Codex CLI state unavailable", + details: codexCliState?.path, + }); + + const storage = await loadAccounts(); + let fixChanged = false; + let fixActions: DoctorFixAction[] = []; + if (options.fix && storage && storage.accounts.length > 0) { + const fixed = applyDoctorFixes(storage, deps); + fixChanged = fixed.changed; + fixActions = fixed.actions; + if (fixChanged && !options.dryRun) { + await saveAccounts(storage); + } + addCheck({ + key: "auto-fix", + severity: fixChanged ? "warn" : "ok", + message: fixChanged + ? options.dryRun + ? `Prepared ${fixActions.length} fix(es) (dry-run)` + : `Applied ${fixActions.length} fix(es)` + : "No safe auto-fixes needed", + }); + } + if (!storage || storage.accounts.length === 0) { + addCheck({ + key: "accounts", + severity: "warn", + message: "No accounts configured", + }); + } else { + addCheck({ + key: "accounts", + severity: "ok", + message: `Loaded ${storage.accounts.length} account(s)`, + }); + + const activeIndex = deps.resolveActiveIndex(storage, "codex"); + const activeExists = activeIndex >= 0 && activeIndex < storage.accounts.length; + addCheck({ + key: "active-index", + severity: activeExists ? "ok" : "error", + message: activeExists + ? `Active index is valid (${activeIndex + 1})` + : "Active index is out of range", + }); + + const disabledCount = storage.accounts.filter((a) => a.enabled === false).length; + addCheck({ + key: "enabled-accounts", + severity: disabledCount >= storage.accounts.length ? "error" : "ok", + message: + disabledCount >= storage.accounts.length + ? "All accounts are disabled" + : `${storage.accounts.length - disabledCount} enabled / ${disabledCount} disabled`, + }); + + const seenRefreshTokens = new Set(); + let duplicateTokenCount = 0; + for (const account of storage.accounts) { + const token = getDoctorRefreshTokenKey(account.refreshToken); + if (!token) continue; + if (seenRefreshTokens.has(token)) { + duplicateTokenCount += 1; + } else { + seenRefreshTokens.add(token); + } + } + addCheck({ + key: "duplicate-refresh-token", + severity: duplicateTokenCount > 0 ? "warn" : "ok", + message: + duplicateTokenCount > 0 + ? `Detected ${duplicateTokenCount} duplicate refresh token entr${duplicateTokenCount === 1 ? "y" : "ies"}` + : "No duplicate refresh tokens detected", + }); + + const seenEmails = new Set(); + let duplicateEmailCount = 0; + let placeholderEmailCount = 0; + let likelyInvalidRefreshTokenCount = 0; + for (const account of storage.accounts) { + const email = sanitizeEmail(account.email); + if (!email) continue; + if (seenEmails.has(email)) duplicateEmailCount += 1; + seenEmails.add(email); + if (hasPlaceholderEmail(email)) placeholderEmailCount += 1; + if (deps.hasLikelyInvalidRefreshToken(account.refreshToken)) { + likelyInvalidRefreshTokenCount += 1; + } + } + addCheck({ + key: "duplicate-email", + severity: duplicateEmailCount > 0 ? "warn" : "ok", + message: + duplicateEmailCount > 0 + ? `Detected ${duplicateEmailCount} duplicate email entr${duplicateEmailCount === 1 ? "y" : "ies"}` + : "No duplicate emails detected", + }); + addCheck({ + key: "placeholder-email", + severity: placeholderEmailCount > 0 ? "warn" : "ok", + message: + placeholderEmailCount > 0 + ? `${placeholderEmailCount} account(s) appear to be placeholder/demo entries` + : "No placeholder emails detected", + }); + addCheck({ + key: "refresh-token-shape", + severity: likelyInvalidRefreshTokenCount > 0 ? "warn" : "ok", + message: + likelyInvalidRefreshTokenCount > 0 + ? `${likelyInvalidRefreshTokenCount} account(s) have likely invalid refresh token format` + : "Refresh token format looks normal", + }); + + const now = Date.now(); + const forecastResults = evaluateForecastAccounts( + storage.accounts.map((account, index) => ({ + index, + account, + isCurrent: index === activeIndex, + now, + })), + ); + const recommendation = recommendForecastAccount(forecastResults); + if ( + recommendation.recommendedIndex !== null + && recommendation.recommendedIndex !== activeIndex + ) { + addCheck({ + key: "recommended-switch", + severity: "warn", + message: `A healthier account is available: switch to ${recommendation.recommendedIndex + 1}`, + details: recommendation.reason, + }); + } else { + addCheck({ + key: "recommended-switch", + severity: "ok", + message: "Current account aligns with forecast recommendation", + }); + } + + if (activeExists) { + const activeAccount = storage.accounts[activeIndex]; + const managerActiveEmail = sanitizeEmail(activeAccount?.email); + const managerActiveAccountId = activeAccount?.accountId; + const codexActiveEmail = + sanitizeEmail(codexCliState?.activeEmail) ?? codexAuthEmail; + const codexActiveAccountId = + codexCliState?.activeAccountId ?? codexAuthAccountId; + const isEmailMismatch = + !!managerActiveEmail + && !!codexActiveEmail + && managerActiveEmail !== codexActiveEmail; + const isAccountIdMismatch = + !!managerActiveAccountId + && !!codexActiveAccountId + && managerActiveAccountId !== codexActiveAccountId; + + addCheck({ + key: "active-selection-sync", + severity: isEmailMismatch || isAccountIdMismatch ? "warn" : "ok", + message: + isEmailMismatch || isAccountIdMismatch + ? "Manager active account and Codex active account are not aligned" + : "Manager active account and Codex active account are aligned", + details: `manager=${managerActiveEmail ?? managerActiveAccountId ?? "unknown"} | codex=${codexActiveEmail ?? codexActiveAccountId ?? "unknown"}`, + }); + + if (options.fix && activeAccount) { + let syncAccessToken = activeAccount.accessToken; + let syncRefreshToken = activeAccount.refreshToken; + let syncExpiresAt = activeAccount.expiresAt; + let syncIdToken: string | undefined; + let storageChangedFromDoctorSync = false; + + if (!deps.hasUsableAccessToken(activeAccount, now)) { + if (options.dryRun) { + fixActions.push({ + key: "doctor-refresh", + message: `Prepared active-account token refresh for account ${activeIndex + 1} (dry-run)`, + }); + } else { + const refreshResult = await queuedRefresh(activeAccount.refreshToken); + if (refreshResult.type === "success") { + const refreshedEmail = sanitizeEmail( + extractAccountEmail(refreshResult.access, refreshResult.idToken), + ); + const refreshedAccountId = extractAccountId(refreshResult.access); + activeAccount.accessToken = refreshResult.access; + activeAccount.refreshToken = refreshResult.refresh; + activeAccount.expiresAt = refreshResult.expires; + if (refreshedEmail) activeAccount.email = refreshedEmail; + deps.applyTokenAccountIdentity(activeAccount, refreshedAccountId); + syncAccessToken = refreshResult.access; + syncRefreshToken = refreshResult.refresh; + syncExpiresAt = refreshResult.expires; + syncIdToken = refreshResult.idToken; + storageChangedFromDoctorSync = true; + fixActions.push({ + key: "doctor-refresh", + message: `Refreshed active account tokens for account ${activeIndex + 1}`, + }); + } else { + addCheck({ + key: "doctor-refresh", + severity: "warn", + message: "Unable to refresh active account before Codex sync", + details: deps.normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ), + }); + } + } + } + + if (storageChangedFromDoctorSync) { + fixChanged = true; + if (!options.dryRun) { + await saveAccounts(storage); + } + } + + if (!options.dryRun) { + const synced = await setCodexCliActiveSelection({ + accountId: activeAccount.accountId, + email: activeAccount.email, + accessToken: syncAccessToken, + refreshToken: syncRefreshToken, + expiresAt: syncExpiresAt, + ...(syncIdToken ? { idToken: syncIdToken } : {}), + }); + if (synced) { + fixChanged = true; + fixActions.push({ + key: "codex-active-sync", + message: "Synced manager active account into Codex auth state", + }); + } else { + addCheck({ + key: "codex-active-sync", + severity: "warn", + message: "Failed to sync manager active account into Codex auth state", + }); + } + } else { + fixActions.push({ + key: "codex-active-sync", + message: "Prepared Codex active-account sync (dry-run)", + }); + } + } + } + } + + const summary = checks.reduce( + (acc, check) => { + acc[check.severity] += 1; + return acc; + }, + { ok: 0, warn: 0, error: 0 }, + ); + + if (options.json) { + console.log( + JSON.stringify( + { + command: "doctor", + storagePath, + summary, + checks, + fix: { + enabled: options.fix, + dryRun: options.dryRun, + changed: fixChanged, + actions: fixActions, + }, + }, + null, + 2, + ), + ); + return summary.error > 0 ? 1 : 0; + } + + console.log("Doctor diagnostics"); + console.log(`Storage: ${storagePath}`); + console.log( + `Summary: ${summary.ok} ok, ${summary.warn} warnings, ${summary.error} errors`, + ); + console.log(""); + for (const check of checks) { + const marker = + check.severity === "ok" ? "✓" : check.severity === "warn" ? "!" : "✗"; + console.log(`${marker} ${check.key}: ${check.message}`); + if (check.details) { + console.log(` ${check.details}`); + } + } + if (options.fix) { + console.log(""); + if (fixActions.length > 0) { + console.log(`Auto-fix actions (${options.dryRun ? "dry-run" : "applied"}):`); + for (const action of fixActions) { + console.log(` - ${action.message}`); + } + } else { + console.log("Auto-fix actions: none"); + } + } + + return summary.error > 0 ? 1 : 0; +} From 12a201cd22faf174ed305b9d3e831bbcffd6d17f Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 01:10:25 +0800 Subject: [PATCH 008/376] docs: fix daily use portal links --- docs/README.md | 6 +++--- test/documentation.test.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/README.md b/docs/README.md index e29c1f05..f1ff8bc9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,7 +7,7 @@ Public documentation for `codex-multi-auth`. ## Start Here 1. [Getting Started](getting-started.md) -2. [index.md](index.md) +2. [Daily-use landing page](index.md) 3. [FAQ](faq.md) 4. [Troubleshooting](troubleshooting.md) @@ -17,7 +17,7 @@ Public documentation for `codex-multi-auth`. | Document | Focus | | --- | --- | -| [index.md](index.md) | Daily-use landing page for common `codex auth ...` workflows | +| [Daily-use landing page](index.md) | Daily-use landing page for common `codex auth ...` workflows | | [faq.md](faq.md) | Short answers to common adoption questions | | [features.md](features.md) | User-facing capability map | | [configuration.md](configuration.md) | Stable defaults, precedence, and environment overrides | @@ -53,7 +53,7 @@ Public documentation for `codex-multi-auth`. | [reference/error-contracts.md](reference/error-contracts.md) | CLI, JSON, and helper error semantics | | [releases/v1.1.10.md](releases/v1.1.10.md) | Current stable release notes | | [releases/v0.1.0-beta.0.md](releases/v0.1.0-beta.0.md) | Archived prerelease reference | -| [User Guides release notes](#user-guides) | Stable, previous, and archived release notes | +| [Daily Use release notes](#daily-use) | Stable, previous, and archived release notes | | [releases/legacy-pre-0.1-history.md](releases/legacy-pre-0.1-history.md) | Archived pre-0.1 changelog history | --- diff --git a/test/documentation.test.ts b/test/documentation.test.ts index 1c696d36..252b1ff2 100644 --- a/test/documentation.test.ts +++ b/test/documentation.test.ts @@ -123,7 +123,7 @@ describe("Documentation Integrity", () => { expect(portal).toContain("releases/v0.1.0-beta.0.md"); expect(portal).toContain("releases/legacy-pre-0.1-history.md"); expect(portal).toContain( - "| [User Guides release notes](#user-guides) | Stable, previous, and archived release notes |", + "| [Daily Use release notes](#daily-use) | Stable, previous, and archived release notes |", ); const beta = read("docs/releases/v0.1.0-beta.0.md"); From e4c9afb0638e39680aba2294accbf1bf1560bd9b Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 01:16:17 +0800 Subject: [PATCH 009/376] fix(cli): persist repair commands through storage tx --- lib/codex-manager/repair-commands.ts | 110 +++++++++++++++++++++++---- test/codex-manager-cli.test.ts | 82 +++++++++++++++++++- 2 files changed, 176 insertions(+), 16 deletions(-) diff --git a/lib/codex-manager/repair-commands.ts b/lib/codex-manager/repair-commands.ts index e17ca05e..0c76d352 100644 --- a/lib/codex-manager/repair-commands.ts +++ b/lib/codex-manager/repair-commands.ts @@ -20,9 +20,9 @@ import { getStoragePath, loadAccounts, loadFlaggedAccounts, - saveAccounts, saveFlaggedAccounts, setStoragePath, + withAccountStorageTransaction, withAccountAndFlaggedStorageTransaction, type AccountMetadataV3, type AccountStorageV3, @@ -335,6 +335,69 @@ function createEmptyAccountStorage(): AccountStorageV3 { }; } +type AccountStorageMutation = { + before: AccountMetadataV3; + after: AccountMetadataV3; +}; + +function hasAccountStorageMutation( + before: AccountMetadataV3, + after: AccountMetadataV3, +): boolean { + return ( + before.refreshToken !== after.refreshToken + || before.accessToken !== after.accessToken + || before.expiresAt !== after.expiresAt + || before.email !== after.email + || before.accountId !== after.accountId + || before.accountIdSource !== after.accountIdSource + || before.enabled !== after.enabled + ); +} + +function collectAccountStorageMutations( + beforeAccounts: readonly AccountMetadataV3[], + afterAccounts: readonly AccountMetadataV3[], +): AccountStorageMutation[] { + const mutations: AccountStorageMutation[] = []; + for (let i = 0; i < afterAccounts.length; i += 1) { + const before = beforeAccounts[i]; + const after = afterAccounts[i]; + if (!before || !after) continue; + if (!hasAccountStorageMutation(before, after)) continue; + mutations.push({ + before: structuredClone(before), + after: structuredClone(after), + }); + } + return mutations; +} + +function applyAccountStorageMutations( + storage: AccountStorageV3, + mutations: readonly AccountStorageMutation[], +): void { + for (const mutation of mutations) { + const targetIndex = + findMatchingAccountIndex(storage.accounts, mutation.before, { + allowUniqueAccountIdFallbackWithoutEmail: true, + }) + ?? findMatchingAccountIndex(storage.accounts, mutation.after, { + allowUniqueAccountIdFallbackWithoutEmail: true, + }); + if (targetIndex === undefined) continue; + const target = storage.accounts[targetIndex]; + if (!target) continue; + target.refreshToken = mutation.after.refreshToken; + target.accessToken = mutation.after.accessToken; + target.expiresAt = mutation.after.expiresAt; + target.email = mutation.after.email; + target.accountId = mutation.after.accountId; + target.accountIdSource = mutation.after.accountIdSource; + target.enabled = mutation.after.enabled; + } +} + function findExistingAccountIndexForFlagged( storage: AccountStorageV3, flagged: FlaggedAccountMetadataV1, @@ -908,6 +971,7 @@ export async function runFix( console.log("No accounts configured."); return 0; } + const originalAccounts = storage.accounts.map((account) => structuredClone(account)); let quotaEmailFallbackState = options.live && quotaCache ? deps.buildQuotaEmailFallbackState(storage.accounts) @@ -1156,9 +1220,19 @@ export async function runFix( ); const recommendation = recommendForecastAccount(forecastResults); const reportSummary = summarizeFixReports(reports); + const accountMutations = collectAccountStorageMutations( + originalAccounts, + storage.accounts, + ); if (changed && !options.dryRun) { - await saveAccounts(storage); + await withAccountStorageTransaction(async (loadedStorage, persist) => { + const nextStorage = loadedStorage + ? structuredClone(loadedStorage) + : createEmptyAccountStorage(); + applyAccountStorageMutations(nextStorage, accountMutations); + await persist(nextStorage); + }); } if (options.json) { @@ -1438,15 +1512,15 @@ export async function runDoctor( }); const storage = await loadAccounts(); + const originalDoctorAccounts = storage?.accounts.map((account) => structuredClone(account)) ?? []; let fixChanged = false; + let storageFixChanged = false; let fixActions: DoctorFixAction[] = []; if (options.fix && storage && storage.accounts.length > 0) { const fixed = applyDoctorFixes(storage, deps); + storageFixChanged = fixed.changed; fixChanged = fixed.changed; fixActions = fixed.actions; - if (fixChanged && !options.dryRun) { - await saveAccounts(storage); - } addCheck({ key: "auto-fix", severity: fixChanged ? "warn" : "ok", @@ -1609,7 +1683,6 @@ export async function runDoctor( let syncRefreshToken = activeAccount.refreshToken; let syncExpiresAt = activeAccount.expiresAt; let syncIdToken: string | undefined; - let storageChangedFromDoctorSync = false; if (!deps.hasUsableAccessToken(activeAccount, now)) { if (options.dryRun) { @@ -1633,7 +1706,8 @@ export async function runDoctor( syncRefreshToken = refreshResult.refresh; syncExpiresAt = refreshResult.expires; syncIdToken = refreshResult.idToken; - storageChangedFromDoctorSync = true; + storageFixChanged = true; + fixChanged = true; fixActions.push({ key: "doctor-refresh", message: `Refreshed active account tokens for account ${activeIndex + 1}`, @@ -1652,13 +1726,6 @@ export async function runDoctor( } } - if (storageChangedFromDoctorSync) { - fixChanged = true; - if (!options.dryRun) { - await saveAccounts(storage); - } - } - if (!options.dryRun) { const synced = await setCodexCliActiveSelection({ accountId: activeAccount.accountId, @@ -1691,6 +1758,21 @@ export async function runDoctor( } } + if (options.fix && storage && storage.accounts.length > 0 && storageFixChanged && !options.dryRun) { + const doctorAccountMutations = collectAccountStorageMutations( + originalDoctorAccounts, + storage.accounts, + ); + await withAccountStorageTransaction(async (loadedStorage, persist) => { + const nextStorage = loadedStorage + ? structuredClone(loadedStorage) + : createEmptyAccountStorage(); + applyAccountStorageMutations(nextStorage, doctorAccountMutations); + normalizeDoctorIndexes(nextStorage); + await persist(nextStorage); + }); + } + const summary = checks.reduce( (acc, check) => { acc[check.severity] += 1; diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 930cf8fb..ff7289d2 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -501,7 +501,11 @@ describe("codex manager cli commands", () => { }); withAccountStorageTransactionMock.mockImplementation( async (handler) => { - const current = await loadAccountsMock(); + const previousCallCount = loadAccountsMock.mock.calls.length; + let current = await loadAccountsMock(); + if (current == null && previousCallCount > 0) { + current = await loadAccountsMock.mock.results[previousCallCount - 1]?.value; + } return handler( current == null ? { @@ -517,7 +521,11 @@ describe("codex manager cli commands", () => { ); withAccountAndFlaggedStorageTransactionMock.mockImplementation( async (handler) => { - const current = await loadAccountsMock(); + const previousCallCount = loadAccountsMock.mock.calls.length; + let current = await loadAccountsMock(); + if (current == null && previousCallCount > 0) { + current = await loadAccountsMock.mock.results[previousCallCount - 1]?.value; + } let snapshot = current == null ? { @@ -1923,6 +1931,7 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "fix", "--json"]); expect(exitCode).toBe(0); + expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); expect(saveAccountsMock).toHaveBeenCalledTimes(1); expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); @@ -5670,6 +5679,75 @@ describe("codex manager cli commands", () => { expect(payload.fix.actions.length).toBeGreaterThan(0); }); + it("runs doctor --fix apply mode in a single storage transaction", async () => { + const now = Date.now(); + let storageState = { + version: 3, + activeIndex: 4, + activeIndexByFamily: { codex: 4 }, + accounts: [ + { + email: "account1@example.com", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: false, + }, + { + email: "account2@example.com", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: false, + }, + ], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "access-doctor-next", + refresh: "refresh-doctor-next", + expires: now + 3_600_000, + idToken: "id-doctor-next", + }); + setCodexCliActiveSelectionMock.mockResolvedValueOnce(true); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli([ + "auth", + "doctor", + "--fix", + "--json", + ]); + + expect(exitCode).toBe(0); + expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledTimes(1); + expect(storageState.activeIndex).toBe(1); + expect(storageState.activeIndexByFamily.codex).toBe(1); + expect(storageState.accounts[1]?.enabled).toBe(true); + expect(storageState.accounts[1]?.refreshToken).toBe("refresh-doctor-next"); + + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + fix: { + changed: boolean; + actions: Array<{ key: string }>; + }; + }; + expect(payload.fix.changed).toBe(true); + expect(payload.fix.actions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: "doctor-refresh" }), + expect.objectContaining({ key: "codex-active-sync" }), + ]), + ); + }); + it("runs report command in json mode", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValueOnce({ From 53c6e509a57f0e4c6efa2ff99666b65a61786a62 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 01:26:24 +0800 Subject: [PATCH 010/376] fix(cli):transact-verify-flagged-persistence --- lib/codex-manager/repair-commands.ts | 20 +++++++++++++------- test/codex-manager-cli.test.ts | 8 +++++++- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/lib/codex-manager/repair-commands.ts b/lib/codex-manager/repair-commands.ts index 0c76d352..2df8ae6f 100644 --- a/lib/codex-manager/repair-commands.ts +++ b/lib/codex-manager/repair-commands.ts @@ -20,7 +20,6 @@ import { getStoragePath, loadAccounts, loadFlaggedAccounts, - saveFlaggedAccounts, setStoragePath, withAccountStorageTransaction, withAccountAndFlaggedStorageTransaction, @@ -845,10 +844,12 @@ export async function runVerifyFlagged( ? structuredClone(loadedStorage) : createEmptyAccountStorage(); applyRefreshChecks(nextStorage); - if (!storageChanged) { + if (!storageChanged && !flaggedChanged) { return; } - normalizeDoctorIndexes(nextStorage); + if (storageChanged) { + normalizeDoctorIndexes(nextStorage); + } await persist(nextStorage, { version: 1, accounts: nextFlaggedAccounts, @@ -869,10 +870,15 @@ export async function runVerifyFlagged( ).length; const changed = storageChanged || flaggedChanged; - if (!options.dryRun && flaggedChanged && (!options.restore || !storageChanged)) { - await saveFlaggedAccounts({ - version: 1, - accounts: nextFlaggedAccounts, + if (!options.dryRun && flaggedChanged && !options.restore) { + await withAccountAndFlaggedStorageTransaction(async (loadedStorage, persist) => { + const nextStorage = loadedStorage + ? structuredClone(loadedStorage) + : createEmptyAccountStorage(); + await persist(nextStorage, { + version: 1, + accounts: nextFlaggedAccounts, + }); }); } diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index ff7289d2..d594ca26 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1387,7 +1387,13 @@ describe("codex manager cli commands", () => { "--json", ]); expect(exitCode).toBe(0); - expect(saveAccountsMock).not.toHaveBeenCalled(); + expect(withAccountAndFlaggedStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).toHaveBeenCalledWith( + expect.objectContaining({ + accounts: [], + }), + ); expect(saveFlaggedAccountsMock).toHaveBeenCalledTimes(1); expect(saveFlaggedAccountsMock).toHaveBeenCalledWith( expect.objectContaining({ From 6ee3665945d5c79d3108447529957f8b1d0f7a0e Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 02:32:05 +0800 Subject: [PATCH 011/376] fix(cli): close remaining repair review gaps --- lib/codex-manager.ts | 164 +++++++++++---- lib/codex-manager/repair-commands.ts | 92 +++++++-- test/codex-manager-cli.test.ts | 288 ++++++++++++++++++++++++++- 3 files changed, 486 insertions(+), 58 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index b8a91c52..d9920c02 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -2055,6 +2055,52 @@ async function clearAccountsAndReset(): Promise { await clearAccounts(); } +function resetManageActionSelection(storage: AccountStorageV3): void { + storage.activeIndex = 0; + storage.activeIndexByFamily = {}; + for (const family of MODEL_FAMILIES) { + storage.activeIndexByFamily[family] = 0; + } +} + +function replaceManageActionStorage( + target: AccountStorageV3, + source: AccountStorageV3, +): void { + target.version = source.version; + target.accounts = structuredClone(source.accounts); + target.activeIndex = source.activeIndex; + target.activeIndexByFamily = { + ...source.activeIndexByFamily, + }; +} + +function resolveManageActionAccountIndex( + storage: AccountStorageV3, + fallbackIndex: number, + account: AccountMetadataV3 | undefined, +): number | null { + if (account) { + const matchedIndex = findMatchingAccountIndex( + storage.accounts, + { + accountId: account.accountId, + email: account.email, + refreshToken: account.refreshToken, + }, + { + allowUniqueAccountIdFallbackWithoutEmail: true, + }, + ); + if (typeof matchedIndex === "number" && matchedIndex >= 0) { + return matchedIndex; + } + } + return fallbackIndex >= 0 && fallbackIndex < storage.accounts.length + ? fallbackIndex + : null; +} + async function handleManageAction( storage: AccountStorageV3, menuResult: Awaited>, @@ -2067,14 +2113,29 @@ async function handleManageAction( if (typeof menuResult.deleteAccountIndex === "number") { const idx = menuResult.deleteAccountIndex; - if (idx >= 0 && idx < storage.accounts.length) { - storage.accounts.splice(idx, 1); - storage.activeIndex = 0; - storage.activeIndexByFamily = {}; - for (const family of MODEL_FAMILIES) { - storage.activeIndexByFamily[family] = 0; - } - await saveAccounts(storage); + const selectedAccount = storage.accounts[idx]; + let deleted = false; + if (selectedAccount) { + await withAccountStorageTransaction(async (loadedStorage, persist) => { + const nextStorage = loadedStorage + ? structuredClone(loadedStorage) + : structuredClone(storage); + const nextIndex = resolveManageActionAccountIndex( + nextStorage, + idx, + selectedAccount, + ); + if (nextIndex === null) { + return; + } + nextStorage.accounts.splice(nextIndex, 1); + resetManageActionSelection(nextStorage); + await persist(nextStorage); + replaceManageActionStorage(storage, nextStorage); + deleted = true; + }); + } + if (deleted) { console.log(`Deleted account ${idx + 1}.`); } return; @@ -2082,12 +2143,34 @@ async function handleManageAction( if (typeof menuResult.toggleAccountIndex === "number") { const idx = menuResult.toggleAccountIndex; - const account = storage.accounts[idx]; - if (account) { - account.enabled = account.enabled === false; - await saveAccounts(storage); + const selectedAccount = storage.accounts[idx]; + let nextEnabledState: boolean | null = null; + if (selectedAccount) { + await withAccountStorageTransaction(async (loadedStorage, persist) => { + const nextStorage = loadedStorage + ? structuredClone(loadedStorage) + : structuredClone(storage); + const nextIndex = resolveManageActionAccountIndex( + nextStorage, + idx, + selectedAccount, + ); + if (nextIndex === null) { + return; + } + const nextAccount = nextStorage.accounts[nextIndex]; + if (!nextAccount) { + return; + } + nextAccount.enabled = nextAccount.enabled === false; + await persist(nextStorage); + replaceManageActionStorage(storage, nextStorage); + nextEnabledState = nextAccount.enabled !== false; + }); + } + if (nextEnabledState !== null) { console.log( - `${account.enabled === false ? "Disabled" : "Enabled"} account ${idx + 1}.`, + `${nextEnabledState ? "Enabled" : "Disabled"} account ${idx + 1}.`, ); } return; @@ -2166,33 +2249,36 @@ export async function autoSyncActiveAccountToCodex(): Promise { if (!hasUsableAccessToken(account, now)) { const refreshResult = await queuedRefresh(account.refreshToken); - if (refreshResult.type === "success") { - const tokenAccountId = extractAccountId(refreshResult.access); - const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)); - if (account.refreshToken !== refreshResult.refresh) { - account.refreshToken = refreshResult.refresh; - changed = true; - } - if (account.accessToken !== refreshResult.access) { - account.accessToken = refreshResult.access; - changed = true; - } - if (account.expiresAt !== refreshResult.expires) { - account.expiresAt = refreshResult.expires; - changed = true; - } - if (nextEmail && nextEmail !== account.email) { - account.email = nextEmail; - changed = true; - } - if (applyTokenAccountIdentity(account, tokenAccountId)) { - changed = true; - } - syncAccessToken = refreshResult.access; - syncRefreshToken = refreshResult.refresh; - syncExpiresAt = refreshResult.expires; - syncIdToken = refreshResult.idToken; + if (refreshResult.type !== "success") { + return false; + } + const tokenAccountId = extractAccountId(refreshResult.access); + const nextEmail = sanitizeEmail( + extractAccountEmail(refreshResult.access, refreshResult.idToken), + ); + if (account.refreshToken !== refreshResult.refresh) { + account.refreshToken = refreshResult.refresh; + changed = true; + } + if (account.accessToken !== refreshResult.access) { + account.accessToken = refreshResult.access; + changed = true; + } + if (account.expiresAt !== refreshResult.expires) { + account.expiresAt = refreshResult.expires; + changed = true; + } + if (nextEmail && nextEmail !== account.email) { + account.email = nextEmail; + changed = true; + } + if (applyTokenAccountIdentity(account, tokenAccountId)) { + changed = true; } + syncAccessToken = refreshResult.access; + syncRefreshToken = refreshResult.refresh; + syncExpiresAt = refreshResult.expires; + syncIdToken = refreshResult.idToken; } if (changed) { diff --git a/lib/codex-manager/repair-commands.ts b/lib/codex-manager/repair-commands.ts index 2df8ae6f..7b4b2f7e 100644 --- a/lib/codex-manager/repair-commands.ts +++ b/lib/codex-manager/repair-commands.ts @@ -543,6 +543,48 @@ interface DoctorFixAction { message: string; } +function maskDoctorEmail(value: string | undefined): string | undefined { + if (!value) return undefined; + const email = value.trim(); + const atIndex = email.indexOf("@"); + if (atIndex < 0) return "***@***"; + const local = email.slice(0, atIndex); + const domain = email.slice(atIndex + 1); + const parts = domain.split("."); + const tld = parts.pop() || ""; + const prefix = local.slice(0, Math.min(2, local.length)); + return `${prefix}***@***.${tld}`; +} + +function redactDoctorIdentifier(value: string | undefined): string | undefined { + if (!value) return undefined; + const identifier = value.trim(); + if (!identifier) return undefined; + if (identifier.includes("@")) { + return maskDoctorEmail(identifier); + } + if (identifier.length <= 8) { + return "***"; + } + return `${identifier.slice(0, 4)}***${identifier.slice(-3)}`; +} + +function formatDoctorIdentitySummary(identity: { + email?: string; + accountId?: string; +}): string { + const parts: string[] = []; + const maskedEmail = maskDoctorEmail(identity.email); + const maskedAccountId = redactDoctorIdentifier(identity.accountId); + if (maskedEmail) { + parts.push(`email=${maskedEmail}`); + } + if (maskedAccountId) { + parts.push(`accountId=${maskedAccountId}`); + } + return parts.join(", ") || "unknown"; +} + function hasPlaceholderEmail(value: string | undefined): boolean { if (!value) return false; const email = value.trim().toLowerCase(); @@ -1006,6 +1048,7 @@ export async function runFix( } if (deps.hasUsableAccessToken(account, now)) { + let refreshAfterLiveProbeFailure = false; if (options.live) { const currentAccessToken = account.accessToken; const probeAccountId = currentAccessToken @@ -1048,20 +1091,23 @@ export async function runFix( outcome: "warning-soft-failure", message: `live probe failed (${message}), trying refresh fallback`, }); + refreshAfterLiveProbeFailure = true; } } } - const refreshWarning = deps.hasLikelyInvalidRefreshToken(account.refreshToken) - ? " (refresh token looks stale; re-login recommended)" - : ""; - reports.push({ - index: i, - label, - outcome: "healthy", - message: `access token still valid${refreshWarning}`, - }); - continue; + if (!refreshAfterLiveProbeFailure) { + const refreshWarning = deps.hasLikelyInvalidRefreshToken(account.refreshToken) + ? " (refresh token looks stale; re-login recommended)" + : ""; + reports.push({ + index: i, + label, + outcome: "healthy", + message: `access token still valid${refreshWarning}`, + }); + continue; + } } const refreshResult = await queuedRefresh(account.refreshToken); @@ -1242,7 +1288,7 @@ export async function runFix( } if (options.json) { - if (workingQuotaCache && quotaCacheChanged) { + if (!options.dryRun && workingQuotaCache && quotaCacheChanged) { await saveQuotaCache(workingQuotaCache); } console.log( @@ -1343,7 +1389,7 @@ export async function runFix( ); } } - if (workingQuotaCache && quotaCacheChanged) { + if (!options.dryRun && workingQuotaCache && quotaCacheChanged) { await saveQuotaCache(workingQuotaCache); } @@ -1456,7 +1502,10 @@ export async function runDoctor( message: "Codex auth file is readable", details: codexAuthEmail || codexAuthAccountId - ? `email=${codexAuthEmail ?? "unknown"}, accountId=${codexAuthAccountId ?? "unknown"}` + ? formatDoctorIdentitySummary({ + email: codexAuthEmail, + accountId: codexAuthAccountId, + }) : undefined, }); } catch (error) { @@ -1681,7 +1730,14 @@ export async function runDoctor( isEmailMismatch || isAccountIdMismatch ? "Manager active account and Codex active account are not aligned" : "Manager active account and Codex active account are aligned", - details: `manager=${managerActiveEmail ?? managerActiveAccountId ?? "unknown"} | codex=${codexActiveEmail ?? codexActiveAccountId ?? "unknown"}`, + details: + `manager=${formatDoctorIdentitySummary({ + email: managerActiveEmail, + accountId: managerActiveAccountId, + })} | codex=${formatDoctorIdentitySummary({ + email: codexActiveEmail, + accountId: codexActiveAccountId, + })}`, }); if (options.fix && activeAccount) { @@ -1689,8 +1745,9 @@ export async function runDoctor( let syncRefreshToken = activeAccount.refreshToken; let syncExpiresAt = activeAccount.expiresAt; let syncIdToken: string | undefined; + let canSyncActiveAccount = deps.hasUsableAccessToken(activeAccount, now); - if (!deps.hasUsableAccessToken(activeAccount, now)) { + if (!canSyncActiveAccount) { if (options.dryRun) { fixActions.push({ key: "doctor-refresh", @@ -1712,6 +1769,7 @@ export async function runDoctor( syncRefreshToken = refreshResult.refresh; syncExpiresAt = refreshResult.expires; syncIdToken = refreshResult.idToken; + canSyncActiveAccount = true; storageFixChanged = true; fixChanged = true; fixActions.push({ @@ -1732,7 +1790,7 @@ export async function runDoctor( } } - if (!options.dryRun) { + if (!options.dryRun && canSyncActiveAccount) { const synced = await setCodexCliActiveSelection({ accountId: activeAccount.accountId, email: activeAccount.email, @@ -1754,7 +1812,7 @@ export async function runDoctor( message: "Failed to sync manager active account into Codex auth state", }); } - } else { + } else if (options.dryRun && canSyncActiveAccount) { fixActions.push({ key: "codex-active-sync", message: "Prepared Codex active-account sync (dry-run)", diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index d594ca26..d609dded 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -252,6 +252,26 @@ function createDeferred(): { return { promise, resolve, reject }; } +async function getLastLoadedAccountSnapshot( + previousCallCount: number, +): Promise { + for ( + let index = Math.min(previousCallCount, loadAccountsMock.mock.results.length) - 1; + index >= 0; + index -= 1 + ) { + const result = loadAccountsMock.mock.results[index]; + if (!result || result.type !== "return") { + continue; + } + const value = await result.value; + if (value != null) { + return value; + } + } + return null; +} + function makeErrnoError(message: string, code: string): NodeJS.ErrnoException { const error = new Error(message) as NodeJS.ErrnoException; error.code = code; @@ -504,7 +524,7 @@ describe("codex manager cli commands", () => { const previousCallCount = loadAccountsMock.mock.calls.length; let current = await loadAccountsMock(); if (current == null && previousCallCount > 0) { - current = await loadAccountsMock.mock.results[previousCallCount - 1]?.value; + current = await getLastLoadedAccountSnapshot(previousCallCount); } return handler( current == null @@ -524,7 +544,7 @@ describe("codex manager cli commands", () => { const previousCallCount = loadAccountsMock.mock.calls.length; let current = await loadAccountsMock(); if (current == null && previousCallCount > 0) { - current = await loadAccountsMock.mock.results[previousCallCount - 1]?.value; + current = await getLastLoadedAccountSnapshot(previousCallCount); } let snapshot = current == null @@ -1352,6 +1372,61 @@ describe("codex manager cli commands", () => { ); }); + it("restores prior storage snapshot when flagged save fails during verify-flagged --no-restore", async () => { + const now = Date.now(); + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + refreshToken: "refresh-existing", + accountId: "acc_existing", + email: "existing@example.com", + addedAt: now - 10_000, + lastUsed: now - 10_000, + }, + ], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + loadFlaggedAccountsMock.mockResolvedValueOnce({ + version: 1, + accounts: [ + { + refreshToken: "flagged-refresh", + accountId: "acc_flagged", + email: "flagged@example.com", + addedAt: now - 5_000, + lastUsed: now - 5_000, + flaggedAt: now - 5_000, + }, + ], + }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "access-restored", + refresh: "refresh-restored", + expires: now + 3_600_000, + }); + saveFlaggedAccountsMock.mockRejectedValueOnce( + new Error("flagged write failed"), + ); + + const originalStorage = structuredClone(storageState); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + await expect( + runCodexMultiAuthCli(["auth", "verify-flagged", "--no-restore", "--json"]), + ).rejects.toThrow("flagged write failed"); + expect(withAccountAndFlaggedStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).toHaveBeenCalledTimes(2); + expect(saveAccountsMock.mock.calls.at(-1)?.[0]).toEqual(originalStorage); + expect(storageState).toEqual(originalStorage); + }); + it("keeps flagged account when verification still fails", async () => { const now = Date.now(); loadFlaggedAccountsMock.mockResolvedValueOnce({ @@ -1452,6 +1527,70 @@ describe("codex manager cli commands", () => { expect(payload.reports[0]?.outcome).toBe("warning-soft-failure"); }); + it("does not persist quota cache during auth fix --dry-run --live", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "live-dry-run@example.com", + accountId: "acc_live_dry_run", + refreshToken: "refresh-live-dry-run", + accessToken: "access-live-dry-run", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + fetchCodexQuotaSnapshotMock.mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 20, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 10, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli([ + "auth", + "fix", + "--dry-run", + "--live", + "--json", + ]); + + expect(exitCode).toBe(0); + expect(saveAccountsMock).not.toHaveBeenCalled(); + expect(saveQuotaCacheMock).not.toHaveBeenCalled(); + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + dryRun: boolean; + liveProbe: boolean; + reports: Array<{ outcome: string; message: string }>; + }; + expect(payload.dryRun).toBe(true); + expect(payload.liveProbe).toBe(true); + expect(payload.reports).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + outcome: "healthy", + message: expect.stringContaining("live session OK"), + }), + ]), + ); + }); + it("persists rotated tokens during auth check and preserves org-selected workspace binding", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValueOnce({ @@ -2884,6 +3023,44 @@ describe("codex manager cli commands", () => { extractAccountIdMock.mockImplementation(() => "acc_test"); }); + it("autoSyncActiveAccountToCodex skips stale Codex sync when refresh fails", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "stale@example.com", + accountId: "workspace-stale", + accountIdSource: "org", + refreshToken: "refresh-stale", + accessToken: "access-stale", + expiresAt: now - 60_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "failed", + reason: "http_error", + statusCode: 401, + message: "refresh expired", + }); + + const { autoSyncActiveAccountToCodex } = await import( + "../lib/codex-manager.js" + ); + const synced = await autoSyncActiveAccountToCodex(); + + expect(synced).toBe(false); + expect(queuedRefreshMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).not.toHaveBeenCalled(); + expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); + }); + it("keeps auth login menu open after switch until user cancels", async () => { const now = Date.now(); const storage = { @@ -5754,6 +5931,110 @@ describe("codex manager cli commands", () => { ); }); + it("skips doctor --fix Codex sync when active refresh fails", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "doctor@example.com", + accountId: "workspace-doctor", + accountIdSource: "org", + refreshToken: "refresh-doctor", + accessToken: "access-doctor", + expiresAt: now - 60_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "failed", + reason: "http_error", + statusCode: 401, + message: "refresh expired", + }); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli([ + "auth", + "doctor", + "--fix", + "--json", + ]); + + expect(exitCode).toBe(0); + expect(saveAccountsMock).not.toHaveBeenCalled(); + expect(withAccountStorageTransactionMock).not.toHaveBeenCalled(); + expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + checks: Array<{ key: string; severity: string; message: string }>; + fix: { + changed: boolean; + actions: Array<{ key: string }>; + }; + }; + expect(payload.fix.changed).toBe(false); + expect(payload.fix.actions).not.toContainEqual( + expect.objectContaining({ key: "codex-active-sync" }), + ); + expect(payload.checks).toContainEqual( + expect.objectContaining({ + key: "doctor-refresh", + severity: "warn", + message: "Unable to refresh active account before Codex sync", + }), + ); + }); + + it("preserves pre-fix storage when doctor --fix transaction save fails", async () => { + const now = Date.now(); + let storageState = { + version: 3, + activeIndex: 4, + activeIndexByFamily: { codex: 4 }, + accounts: [ + { + email: "account1@example.com", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: false, + }, + { + email: "account2@example.com", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: false, + }, + ], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockRejectedValueOnce(new Error("transaction save failed")); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "access-doctor-next", + refresh: "refresh-doctor-next", + expires: now + 3_600_000, + idToken: "id-doctor-next", + }); + setCodexCliActiveSelectionMock.mockResolvedValueOnce(true); + + const originalStorage = structuredClone(storageState); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + await expect( + runCodexMultiAuthCli(["auth", "doctor", "--fix", "--json"]), + ).rejects.toThrow("transaction save failed"); + expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(storageState).toEqual(originalStorage); + }); + it("runs report command in json mode", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValueOnce({ @@ -6918,6 +7199,9 @@ describe("codex manager cli commands", () => { const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { reports: Array<{ outcome: string; message: string }>; }; + expect( + payload.reports.filter((report) => report.outcome === "healthy"), + ).toHaveLength(1); expect( payload.reports.some( (report) => From 874d6b1f79dcb86ceec28520d7b1f209d4e2c65c Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 03:04:57 +0800 Subject: [PATCH 012/376] fix(cli): close remaining repair review gaps --- lib/codex-manager.ts | 82 +++++- lib/codex-manager/repair-commands.ts | 158 ++++++++--- lib/storage.ts | 34 ++- test/codex-manager-cli.test.ts | 376 +++++++++++++++++++++++---- 4 files changed, 549 insertions(+), 101 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index d9920c02..3fc6ea8d 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -2095,12 +2095,29 @@ function resolveManageActionAccountIndex( if (typeof matchedIndex === "number" && matchedIndex >= 0) { return matchedIndex; } + return null; } return fallbackIndex >= 0 && fallbackIndex < storage.accounts.length ? fallbackIndex : null; } +function matchesManageActionAccount( + account: AccountMetadataV3 | undefined, + candidate: AccountMetadataV3 | undefined, +): boolean { + if (!account || !candidate) { + return false; + } + if (account.accountId || candidate.accountId) { + return account.accountId === candidate.accountId; + } + return ( + account.refreshToken === candidate.refreshToken + && sanitizeEmail(account.email) === sanitizeEmail(candidate.email) + ); +} + async function handleManageAction( storage: AccountStorageV3, menuResult: Awaited>, @@ -2128,6 +2145,10 @@ async function handleManageAction( if (nextIndex === null) { return; } + const nextAccount = nextStorage.accounts[nextIndex]; + if (!matchesManageActionAccount(selectedAccount, nextAccount)) { + return; + } nextStorage.accounts.splice(nextIndex, 1); resetManageActionSelection(nextStorage); await persist(nextStorage); @@ -2159,7 +2180,7 @@ async function handleManageAction( return; } const nextAccount = nextStorage.accounts[nextIndex]; - if (!nextAccount) { + if (!nextAccount || !matchesManageActionAccount(selectedAccount, nextAccount)) { return; } nextAccount.enabled = nextAccount.enabled === false; @@ -2239,55 +2260,88 @@ export async function autoSyncActiveAccountToCodex(): Promise { if (!account) { return false; } + const accountMatch = { + accountId: account.accountId, + email: account.email, + refreshToken: account.refreshToken, + }; const now = Date.now(); let syncAccessToken = account.accessToken; let syncRefreshToken = account.refreshToken; let syncExpiresAt = account.expiresAt; let syncIdToken: string | undefined; + let syncAccountId = account.accountId; + let syncEmail = account.email; let changed = false; + let nextStoredAccount: AccountMetadataV3 | null = null; if (!hasUsableAccessToken(account, now)) { const refreshResult = await queuedRefresh(account.refreshToken); if (refreshResult.type !== "success") { return false; } + nextStoredAccount = structuredClone(account); const tokenAccountId = extractAccountId(refreshResult.access); const nextEmail = sanitizeEmail( extractAccountEmail(refreshResult.access, refreshResult.idToken), ); - if (account.refreshToken !== refreshResult.refresh) { - account.refreshToken = refreshResult.refresh; + if (nextStoredAccount.refreshToken !== refreshResult.refresh) { + nextStoredAccount.refreshToken = refreshResult.refresh; changed = true; } - if (account.accessToken !== refreshResult.access) { - account.accessToken = refreshResult.access; + if (nextStoredAccount.accessToken !== refreshResult.access) { + nextStoredAccount.accessToken = refreshResult.access; changed = true; } - if (account.expiresAt !== refreshResult.expires) { - account.expiresAt = refreshResult.expires; + if (nextStoredAccount.expiresAt !== refreshResult.expires) { + nextStoredAccount.expiresAt = refreshResult.expires; changed = true; } - if (nextEmail && nextEmail !== account.email) { - account.email = nextEmail; + if (nextEmail && nextEmail !== nextStoredAccount.email) { + nextStoredAccount.email = nextEmail; changed = true; } - if (applyTokenAccountIdentity(account, tokenAccountId)) { + if (applyTokenAccountIdentity(nextStoredAccount, tokenAccountId)) { changed = true; } syncAccessToken = refreshResult.access; syncRefreshToken = refreshResult.refresh; syncExpiresAt = refreshResult.expires; syncIdToken = refreshResult.idToken; + syncAccountId = nextStoredAccount.accountId; + syncEmail = nextStoredAccount.email; } - if (changed) { - await saveAccounts(storage); + if (changed && nextStoredAccount) { + let persisted = false; + await withAccountStorageTransaction(async (loadedStorage, persist) => { + if (!loadedStorage) { + return; + } + const nextStorage = structuredClone(loadedStorage); + const targetIndex = + findMatchingAccountIndex(nextStorage.accounts, accountMatch, { + allowUniqueAccountIdFallbackWithoutEmail: true, + }) + ?? findMatchingAccountIndex(nextStorage.accounts, nextStoredAccount, { + allowUniqueAccountIdFallbackWithoutEmail: true, + }); + if (targetIndex === undefined) { + return; + } + nextStorage.accounts[targetIndex] = structuredClone(nextStoredAccount); + await persist(nextStorage); + persisted = true; + }); + if (!persisted) { + return false; + } } return setCodexCliActiveSelection({ - accountId: account.accountId, - email: account.email, + accountId: syncAccountId, + email: syncEmail, accessToken: syncAccessToken, refreshToken: syncRefreshToken, expiresAt: syncExpiresAt, diff --git a/lib/codex-manager/repair-commands.ts b/lib/codex-manager/repair-commands.ts index 7b4b2f7e..9835960f 100644 --- a/lib/codex-manager/repair-commands.ts +++ b/lib/codex-manager/repair-commands.ts @@ -23,9 +23,11 @@ import { setStoragePath, withAccountStorageTransaction, withAccountAndFlaggedStorageTransaction, + withFlaggedStorageTransaction, type AccountMetadataV3, type AccountStorageV3, type FlaggedAccountMetadataV1, + type FlaggedAccountStorageV1, } from "../storage.js"; import { getCodexCliAuthPath, @@ -339,6 +341,11 @@ type AccountStorageMutation = { after: AccountMetadataV3; }; +type FlaggedStorageMutation = { + before: FlaggedAccountMetadataV1; + after?: FlaggedAccountMetadataV1; +}; + function hasAccountStorageMutation( before: AccountMetadataV3, after: AccountMetadataV3, @@ -428,6 +435,45 @@ function findExistingAccountIndexForFlagged( return flaggedMatchIndex ?? -1; } +function findMatchingFlaggedAccountIndex( + accounts: readonly FlaggedAccountMetadataV1[], + target: FlaggedAccountMetadataV1, +): number { + const targetEmail = sanitizeEmail(target.email); + return accounts.findIndex((account) => { + if (account.refreshToken === target.refreshToken) { + return true; + } + if (target.accountId && account.accountId === target.accountId) { + if (!targetEmail) { + return true; + } + return sanitizeEmail(account.email) === targetEmail; + } + return Boolean(targetEmail) && sanitizeEmail(account.email) === targetEmail; + }); +} + +function applyFlaggedStorageMutations( + flaggedStorage: FlaggedAccountStorageV1, + mutations: readonly FlaggedStorageMutation[], +): void { + for (const mutation of mutations) { + const targetIndex = findMatchingFlaggedAccountIndex( + flaggedStorage.accounts, + mutation.before, + ); + if (targetIndex < 0) { + continue; + } + if (mutation.after) { + flaggedStorage.accounts[targetIndex] = structuredClone(mutation.after); + continue; + } + flaggedStorage.accounts.splice(targetIndex, 1); + } +} + function upsertRecoveredFlaggedAccount( storage: AccountStorageV3, flagged: FlaggedAccountMetadataV1, @@ -752,6 +798,7 @@ export async function runVerifyFlagged( let flaggedChanged = false; const reports: VerifyFlaggedReport[] = []; const nextFlaggedAccounts: FlaggedAccountMetadataV1[] = []; + const flaggedMutations: FlaggedStorageMutation[] = []; const now = Date.now(); const refreshChecks: Array<{ index: number; @@ -799,6 +846,10 @@ export async function runVerifyFlagged( if (JSON.stringify(nextFlagged) !== JSON.stringify(flagged)) { flaggedChanged = true; } + flaggedMutations.push({ + before: flagged, + after: nextFlagged, + }); reports.push({ index: i, label, @@ -818,6 +869,9 @@ export async function runVerifyFlagged( if (upsertResult.restored) { storageChanged = storageChanged || upsertResult.changed; flaggedChanged = true; + flaggedMutations.push({ + before: flagged, + }); reports.push({ index: i, label, @@ -850,6 +904,10 @@ export async function runVerifyFlagged( if (JSON.stringify(updatedFlagged) !== JSON.stringify(flagged)) { flaggedChanged = true; } + flaggedMutations.push({ + before: flagged, + after: updatedFlagged, + }); reports.push({ index: i, label, @@ -868,6 +926,10 @@ export async function runVerifyFlagged( if ((flagged.lastError ?? "") !== detail) { flaggedChanged = true; } + flaggedMutations.push({ + before: flagged, + after: failedFlagged, + }); reports.push({ index: i, label, @@ -877,32 +939,39 @@ export async function runVerifyFlagged( } }; + let remainingFlagged = nextFlaggedAccounts.length; + if (options.restore) { if (options.dryRun) { applyRefreshChecks((await loadAccounts()) ?? createEmptyAccountStorage()); } else { - await withAccountAndFlaggedStorageTransaction(async (loadedStorage, persist) => { - const nextStorage = loadedStorage - ? structuredClone(loadedStorage) - : createEmptyAccountStorage(); - applyRefreshChecks(nextStorage); - if (!storageChanged && !flaggedChanged) { - return; - } - if (storageChanged) { - normalizeDoctorIndexes(nextStorage); - } - await persist(nextStorage, { - version: 1, - accounts: nextFlaggedAccounts, - }); - }); + await withAccountAndFlaggedStorageTransaction( + async (loadedStorage, persist, loadedFlaggedStorage) => { + const nextStorage = loadedStorage + ? structuredClone(loadedStorage) + : createEmptyAccountStorage(); + const nextFlaggedStorage = structuredClone(loadedFlaggedStorage); + applyRefreshChecks(nextStorage); + applyFlaggedStorageMutations(nextFlaggedStorage, flaggedMutations); + remainingFlagged = nextFlaggedStorage.accounts.length; + if (!storageChanged && !flaggedChanged) { + return; + } + if (storageChanged) { + normalizeDoctorIndexes(nextStorage); + } + await persist(nextStorage, nextFlaggedStorage); + }, + ); } } else { applyRefreshChecks(createEmptyAccountStorage()); } - const remainingFlagged = nextFlaggedAccounts.length; + if (options.dryRun) { + remainingFlagged = nextFlaggedAccounts.length; + } + const restored = reports.filter((report) => report.outcome === "restored").length; const healthyFlagged = reports.filter( (report) => report.outcome === "healthy-flagged", @@ -913,14 +982,11 @@ export async function runVerifyFlagged( const changed = storageChanged || flaggedChanged; if (!options.dryRun && flaggedChanged && !options.restore) { - await withAccountAndFlaggedStorageTransaction(async (loadedStorage, persist) => { - const nextStorage = loadedStorage - ? structuredClone(loadedStorage) - : createEmptyAccountStorage(); - await persist(nextStorage, { - version: 1, - accounts: nextFlaggedAccounts, - }); + await withFlaggedStorageTransaction(async (loadedFlaggedStorage, persist) => { + const nextFlaggedStorage = structuredClone(loadedFlaggedStorage); + applyFlaggedStorageMutations(nextFlaggedStorage, flaggedMutations); + remainingFlagged = nextFlaggedStorage.accounts.length; + await persist(nextFlaggedStorage); }); } @@ -1571,6 +1637,14 @@ export async function runDoctor( let fixChanged = false; let storageFixChanged = false; let fixActions: DoctorFixAction[] = []; + let pendingCodexActiveSync: { + accountId: string | undefined; + email: string | undefined; + accessToken: string | undefined; + refreshToken: string | undefined; + expiresAt: number | undefined; + idToken?: string; + } | null = null; if (options.fix && storage && storage.accounts.length > 0) { const fixed = applyDoctorFixes(storage, deps); storageFixChanged = fixed.changed; @@ -1791,27 +1865,14 @@ export async function runDoctor( } if (!options.dryRun && canSyncActiveAccount) { - const synced = await setCodexCliActiveSelection({ + pendingCodexActiveSync = { accountId: activeAccount.accountId, email: activeAccount.email, accessToken: syncAccessToken, refreshToken: syncRefreshToken, expiresAt: syncExpiresAt, ...(syncIdToken ? { idToken: syncIdToken } : {}), - }); - if (synced) { - fixChanged = true; - fixActions.push({ - key: "codex-active-sync", - message: "Synced manager active account into Codex auth state", - }); - } else { - addCheck({ - key: "codex-active-sync", - severity: "warn", - message: "Failed to sync manager active account into Codex auth state", - }); - } + }; } else if (options.dryRun && canSyncActiveAccount) { fixActions.push({ key: "codex-active-sync", @@ -1837,6 +1898,23 @@ export async function runDoctor( }); } + if (pendingCodexActiveSync) { + const synced = await setCodexCliActiveSelection(pendingCodexActiveSync); + if (synced) { + fixChanged = true; + fixActions.push({ + key: "codex-active-sync", + message: "Synced manager active account into Codex auth state", + }); + } else { + addCheck({ + key: "codex-active-sync", + severity: "warn", + message: "Failed to sync manager active account into Codex auth state", + }); + } + } + const summary = checks.reduce( (acc, check) => { acc[check.severity] += 1; diff --git a/lib/storage.ts b/lib/storage.ts index 0509925d..f2038246 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -2069,6 +2069,15 @@ function cloneAccountStorageForPersistence( }; } +function cloneFlaggedStorageForPersistence( + storage: FlaggedAccountStorageV1 | null | undefined, +): FlaggedAccountStorageV1 { + return { + version: 1, + accounts: structuredClone(storage?.accounts ?? []), + }; +} + export async function withAccountStorageTransaction( handler: ( current: AccountStorageV3 | null, @@ -2100,6 +2109,7 @@ export async function withAccountAndFlaggedStorageTransaction( accountStorage: AccountStorageV3, flaggedStorage: FlaggedAccountStorageV1, ) => Promise, + currentFlagged: FlaggedAccountStorageV1, ) => Promise, ): Promise { return withStorageLock(async () => { @@ -2110,15 +2120,17 @@ export async function withAccountAndFlaggedStorageTransaction( active: true, }; const current = state.snapshot; + const currentFlagged = await loadFlaggedAccountsFromPath(getFlaggedAccountsPath()); const persist = async ( accountStorage: AccountStorageV3, flaggedStorage: FlaggedAccountStorageV1, ): Promise => { const previousAccounts = cloneAccountStorageForPersistence(state.snapshot); const nextAccounts = cloneAccountStorageForPersistence(accountStorage); + const nextFlagged = cloneFlaggedStorageForPersistence(flaggedStorage); await saveAccountsUnlocked(nextAccounts); try { - await saveFlaggedAccountsUnlocked(flaggedStorage); + await saveFlaggedAccountsUnlocked(nextFlagged); state.snapshot = nextAccounts; } catch (error) { try { @@ -2142,11 +2154,29 @@ export async function withAccountAndFlaggedStorageTransaction( } }; return transactionSnapshotContext.run(state, () => - handler(current, persist), + handler(current, persist, currentFlagged), ); }); } +export async function withFlaggedStorageTransaction( + handler: ( + current: FlaggedAccountStorageV1, + persist: (storage: FlaggedAccountStorageV1) => Promise, + ) => Promise, +): Promise { + return withStorageLock(async () => { + const current = await loadFlaggedAccountsFromPath(getFlaggedAccountsPath()); + let snapshot = cloneFlaggedStorageForPersistence(current); + const persist = async (storage: FlaggedAccountStorageV1): Promise => { + const nextStorage = cloneFlaggedStorageForPersistence(storage); + await saveFlaggedAccountsUnlocked(nextStorage); + snapshot = nextStorage; + }; + return handler(structuredClone(snapshot), persist); + }); +} + /** * Persists account storage to disk using atomic write (temp file + rename). * Creates the Codex multi-auth storage directory if it doesn't exist. diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index d609dded..dd67206b 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -30,10 +30,13 @@ const detectOcChatgptMultiAuthTargetMock = vi.fn(); const normalizeAccountStorageMock = vi.fn((value) => value); const withAccountStorageTransactionMock = vi.fn(); const withAccountAndFlaggedStorageTransactionMock = vi.fn(); +const withFlaggedStorageTransactionMock = vi.fn(); const loggerDebugMock = vi.fn(); const loggerInfoMock = vi.fn(); const loggerWarnMock = vi.fn(); const loggerErrorMock = vi.fn(); +let lastAccountStorageSnapshot: unknown = null; +let lastFlaggedStorageSnapshot: unknown = null; vi.mock("../lib/logger.js", () => ({ createLogger: vi.fn(() => ({ @@ -119,12 +122,25 @@ vi.mock("../lib/storage.js", async () => { const actual = await vi.importActual("../lib/storage.js"); return { ...(actual as Record), - loadAccounts: loadAccountsMock, - loadFlaggedAccounts: loadFlaggedAccountsMock, + loadAccounts: async (...args: unknown[]) => { + const value = await loadAccountsMock(...args); + if (value != null) { + updateLastAccountStorageSnapshot(value); + } + return value; + }, + loadFlaggedAccounts: async (...args: unknown[]) => { + const value = await loadFlaggedAccountsMock(...args); + if (value != null) { + updateLastFlaggedStorageSnapshot(value); + } + return value; + }, saveAccounts: saveAccountsMock, saveFlaggedAccounts: saveFlaggedAccountsMock, withAccountAndFlaggedStorageTransaction: withAccountAndFlaggedStorageTransactionMock, + withFlaggedStorageTransaction: withFlaggedStorageTransactionMock, withAccountStorageTransaction: withAccountStorageTransactionMock, setStoragePath: setStoragePathMock, getStoragePath: getStoragePathMock, @@ -252,24 +268,55 @@ function createDeferred(): { return { promise, resolve, reject }; } -async function getLastLoadedAccountSnapshot( - previousCallCount: number, -): Promise { - for ( - let index = Math.min(previousCallCount, loadAccountsMock.mock.results.length) - 1; - index >= 0; - index -= 1 - ) { - const result = loadAccountsMock.mock.results[index]; - if (!result || result.type !== "return") { - continue; - } - const value = await result.value; - if (value != null) { - return value; +function cloneValue(value: T): T { + return structuredClone(value); +} + +function updateLastAccountStorageSnapshot(snapshot: unknown): void { + if (snapshot == null) { + return; + } + lastAccountStorageSnapshot = cloneValue(snapshot); +} + +function updateLastFlaggedStorageSnapshot(snapshot: unknown): void { + if (snapshot == null) { + return; + } + lastFlaggedStorageSnapshot = cloneValue(snapshot); +} + +function getLastLoadedAccountSnapshot(): unknown { + return lastAccountStorageSnapshot == null + ? null + : cloneValue(lastAccountStorageSnapshot); +} + +function getLastLoadedFlaggedSnapshot(): unknown { + return lastFlaggedStorageSnapshot == null + ? { + version: 1, + accounts: [], } + : cloneValue(lastFlaggedStorageSnapshot); +} + +async function getCurrentAccountSnapshot(): Promise { + const current = await loadAccountsMock(); + if (current != null) { + updateLastAccountStorageSnapshot(current); + return current; + } + return getLastLoadedAccountSnapshot(); +} + +async function getCurrentFlaggedSnapshot(): Promise { + const current = await loadFlaggedAccountsMock(); + if (current != null) { + updateLastFlaggedStorageSnapshot(current); + return current; } - return null; + return getLastLoadedFlaggedSnapshot(); } function makeErrnoError(message: string, code: string): NodeJS.ErrnoException { @@ -491,6 +538,7 @@ describe("codex manager cli commands", () => { restoreAccountsFromBackupMock.mockReset(); withAccountAndFlaggedStorageTransactionMock.mockReset(); withAccountStorageTransactionMock.mockReset(); + withFlaggedStorageTransactionMock.mockReset(); queuedRefreshMock.mockReset(); setCodexCliActiveSelectionMock.mockReset(); promptAddAnotherAccountMock.mockReset(); @@ -519,13 +567,14 @@ describe("codex manager cli commands", () => { version: 1, accounts: [], }); + lastAccountStorageSnapshot = null; + lastFlaggedStorageSnapshot = { + version: 1, + accounts: [], + }; withAccountStorageTransactionMock.mockImplementation( async (handler) => { - const previousCallCount = loadAccountsMock.mock.calls.length; - let current = await loadAccountsMock(); - if (current == null && previousCallCount > 0) { - current = await getLastLoadedAccountSnapshot(previousCallCount); - } + const current = await getCurrentAccountSnapshot(); return handler( current == null ? { @@ -535,17 +584,17 @@ describe("codex manager cli commands", () => { activeIndexByFamily: {}, } : structuredClone(current), - async (storage: unknown) => saveAccountsMock(storage), + async (storage: unknown) => { + await saveAccountsMock(storage); + updateLastAccountStorageSnapshot(storage); + }, ); }, ); withAccountAndFlaggedStorageTransactionMock.mockImplementation( async (handler) => { - const previousCallCount = loadAccountsMock.mock.calls.length; - let current = await loadAccountsMock(); - if (current == null && previousCallCount > 0) { - current = await getLastLoadedAccountSnapshot(previousCallCount); - } + const current = await getCurrentAccountSnapshot(); + const flaggedCurrent = await getCurrentFlaggedSnapshot(); let snapshot = current == null ? { @@ -555,22 +604,50 @@ describe("codex manager cli commands", () => { activeIndexByFamily: {}, } : structuredClone(current); + let flaggedSnapshot = + flaggedCurrent == null + ? { + version: 1, + accounts: [], + } + : structuredClone(flaggedCurrent); return handler( structuredClone(snapshot), async (storage: unknown, flaggedStorage: unknown) => { const previousSnapshot = structuredClone(snapshot); + const previousFlaggedSnapshot = structuredClone(flaggedSnapshot); await saveAccountsMock(storage); try { await saveFlaggedAccountsMock(flaggedStorage); snapshot = structuredClone(storage); + flaggedSnapshot = structuredClone(flaggedStorage); + updateLastAccountStorageSnapshot(snapshot); + updateLastFlaggedStorageSnapshot(flaggedSnapshot); } catch (error) { await saveAccountsMock(previousSnapshot); + updateLastAccountStorageSnapshot(previousSnapshot); + updateLastFlaggedStorageSnapshot(previousFlaggedSnapshot); throw error; } }, + structuredClone(flaggedSnapshot), ); }, ); + withFlaggedStorageTransactionMock.mockImplementation(async (handler) => { + const current = await getCurrentFlaggedSnapshot(); + const snapshot = + current == null + ? { + version: 1, + accounts: [], + } + : structuredClone(current); + return handler(structuredClone(snapshot), async (storage: unknown) => { + await saveFlaggedAccountsMock(storage); + updateLastFlaggedStorageSnapshot(storage); + }); + }); loadDashboardDisplaySettingsMock.mockResolvedValue({ showPerAccountRows: true, showQuotaDetails: true, @@ -1049,6 +1126,63 @@ describe("codex manager cli commands", () => { }); }); + it("preserves concurrently added flagged accounts during flagged recovery", async () => { + const now = Date.now(); + const originalFlagged = { + refreshToken: "flagged-refresh", + accountId: "acc_flagged", + email: "flagged@example.com", + addedAt: now - 1_000, + lastUsed: now - 1_000, + flaggedAt: now - 5_000, + }; + loadFlaggedAccountsMock + .mockResolvedValueOnce({ + version: 1, + accounts: [originalFlagged], + }) + .mockResolvedValueOnce({ + version: 1, + accounts: [ + originalFlagged, + { + refreshToken: "refresh-concurrent", + accountId: "acc_concurrent", + email: "concurrent@example.com", + addedAt: now - 2_000, + lastUsed: now - 2_000, + flaggedAt: now - 2_000, + }, + ], + }); + loadAccountsMock.mockResolvedValueOnce(null); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "access-restored", + refresh: "refresh-restored", + expires: now + 3_600_000, + }); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli([ + "auth", + "verify-flagged", + "--json", + ]); + + expect(exitCode).toBe(0); + expect(withAccountAndFlaggedStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(saveFlaggedAccountsMock).toHaveBeenCalledWith({ + version: 1, + accounts: [ + expect.objectContaining({ + refreshToken: "refresh-concurrent", + accountId: "acc_concurrent", + }), + ], + }); + }); + it("preserves distinct shared-accountId accounts when flagged recovery has no email", async () => { const now = Date.now(); loadFlaggedAccountsMock.mockResolvedValueOnce({ @@ -1421,27 +1555,92 @@ describe("codex manager cli commands", () => { await expect( runCodexMultiAuthCli(["auth", "verify-flagged", "--no-restore", "--json"]), ).rejects.toThrow("flagged write failed"); - expect(withAccountAndFlaggedStorageTransactionMock).toHaveBeenCalledTimes(1); - expect(saveAccountsMock).toHaveBeenCalledTimes(2); - expect(saveAccountsMock.mock.calls.at(-1)?.[0]).toEqual(originalStorage); + expect(withFlaggedStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).not.toHaveBeenCalled(); expect(storageState).toEqual(originalStorage); }); - it("keeps flagged account when verification still fails", async () => { + it("merges concurrently added flagged accounts during verify-flagged --no-restore", async () => { const now = Date.now(); - loadFlaggedAccountsMock.mockResolvedValueOnce({ - version: 1, - accounts: [ - { - refreshToken: "flagged-refresh", - accountId: "acc_flagged", - email: "flagged@example.com", - addedAt: now - 1_000, - lastUsed: now - 1_000, - flaggedAt: now - 5_000, - }, - ], + const originalFlagged = { + refreshToken: "flagged-refresh", + accountId: "acc_flagged", + email: "flagged@example.com", + addedAt: now - 5_000, + lastUsed: now - 5_000, + flaggedAt: now - 5_000, + }; + loadFlaggedAccountsMock + .mockResolvedValueOnce({ + version: 1, + accounts: [originalFlagged], + }) + .mockResolvedValueOnce({ + version: 1, + accounts: [ + originalFlagged, + { + refreshToken: "refresh-concurrent", + accountId: "acc_concurrent", + email: "concurrent@example.com", + addedAt: now - 2_000, + lastUsed: now - 2_000, + flaggedAt: now - 2_000, + }, + ], + }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "access-restored", + refresh: "refresh-restored", + expires: now + 3_600_000, }); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli([ + "auth", + "verify-flagged", + "--no-restore", + "--json", + ]); + + expect(exitCode).toBe(0); + expect(withFlaggedStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).not.toHaveBeenCalled(); + expect(saveFlaggedAccountsMock).toHaveBeenCalledWith( + expect.objectContaining({ + accounts: expect.arrayContaining([ + expect.objectContaining({ + refreshToken: "refresh-restored", + }), + expect.objectContaining({ + refreshToken: "refresh-concurrent", + accountId: "acc_concurrent", + }), + ]), + }), + ); + }); + + it("keeps flagged account when verification still fails", async () => { + const now = Date.now(); + const flaggedAccount = { + refreshToken: "flagged-refresh", + accountId: "acc_flagged", + email: "flagged@example.com", + addedAt: now - 1_000, + lastUsed: now - 1_000, + flaggedAt: now - 5_000, + }; + loadFlaggedAccountsMock + .mockResolvedValueOnce({ + version: 1, + accounts: [flaggedAccount], + }) + .mockResolvedValueOnce({ + version: 1, + accounts: [flaggedAccount], + }); loadAccountsMock.mockResolvedValueOnce({ version: 3, activeIndex: 0, @@ -3023,6 +3222,92 @@ describe("codex manager cli commands", () => { extractAccountIdMock.mockImplementation(() => "acc_test"); }); + it("autoSyncActiveAccountToCodex preserves concurrent storage updates during refresh sync", async () => { + const now = Date.now(); + loadAccountsMock + .mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "a@example.com", + accountId: "workspace-alpha", + accountIdSource: "org", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }) + .mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "a@example.com", + accountId: "workspace-alpha", + accountIdSource: "org", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + { + email: "b@example.com", + accountId: "workspace-beta", + accountIdSource: "org", + refreshToken: "refresh-b", + accessToken: "access-b", + expiresAt: now + 7_200_000, + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + ], + }); + const accountsModule = await import("../lib/accounts.js"); + const extractAccountIdMock = vi.mocked(accountsModule.extractAccountId); + extractAccountIdMock.mockImplementation((accessToken?: string) => + accessToken === "access-a-next" ? "token-personal" : "workspace-alpha", + ); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "access-a-next", + refresh: "refresh-a-next", + expires: now + 3_600_000, + idToken: "id-a-next", + }); + setCodexCliActiveSelectionMock.mockResolvedValueOnce(true); + + const { autoSyncActiveAccountToCodex } = await import( + "../lib/codex-manager.js" + ); + const synced = await autoSyncActiveAccountToCodex(); + + expect(synced).toBe(true); + expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); + const savedStorage = saveAccountsMock.mock.calls.at(-1)?.[0] as { + accounts: Array<{ refreshToken?: string; accountId?: string }>; + }; + expect(savedStorage.accounts).toHaveLength(2); + expect(savedStorage.accounts[0]).toEqual( + expect.objectContaining({ + accountId: "workspace-alpha", + refreshToken: "refresh-a-next", + }), + ); + expect(savedStorage.accounts[1]).toEqual( + expect.objectContaining({ + accountId: "workspace-beta", + refreshToken: "refresh-b", + }), + ); + extractAccountIdMock.mockImplementation(() => "acc_test"); + }); + it("autoSyncActiveAccountToCodex skips stale Codex sync when refresh fails", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValueOnce({ @@ -6032,6 +6317,7 @@ describe("codex manager cli commands", () => { runCodexMultiAuthCli(["auth", "doctor", "--fix", "--json"]), ).rejects.toThrow("transaction save failed"); expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); expect(storageState).toEqual(originalStorage); }); From bdac5b0df3d475a7f427d8233f4bb7a19e5e61ab Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 03:12:32 +0800 Subject: [PATCH 013/376] test(cli): add direct repair command coverage --- test/repair-commands.test.ts | 321 +++++++++++++++++++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 test/repair-commands.test.ts diff --git a/test/repair-commands.test.ts b/test/repair-commands.test.ts new file mode 100644 index 00000000..657a997d --- /dev/null +++ b/test/repair-commands.test.ts @@ -0,0 +1,321 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { RepairCommandDeps } from "../lib/codex-manager/repair-commands.js"; + +const existsSyncMock = vi.fn(); +const statMock = vi.fn(); +const readFileMock = vi.fn(); + +const evaluateForecastAccountsMock = vi.fn(() => []); +const recommendForecastAccountMock = vi.fn(() => ({ + recommendedIndex: null, + reason: "stay", +})); + +const extractAccountEmailMock = vi.fn(); +const extractAccountIdMock = vi.fn(); +const formatAccountLabelMock = vi.fn( + (account: { email?: string }, index: number) => + account.email ? `${index + 1}. ${account.email}` : `Account ${index + 1}`, +); +const sanitizeEmailMock = vi.fn((email: string | undefined) => + typeof email === "string" ? email.toLowerCase() : undefined, +); + +const loadQuotaCacheMock = vi.fn(); +const saveQuotaCacheMock = vi.fn(); +const fetchCodexQuotaSnapshotMock = vi.fn(); +const queuedRefreshMock = vi.fn(); + +const loadAccountsMock = vi.fn(); +const loadFlaggedAccountsMock = vi.fn(); +const setStoragePathMock = vi.fn(); +const getStoragePathMock = vi.fn(() => "/mock/openai-codex-accounts.json"); +const withAccountStorageTransactionMock = vi.fn(); +const withAccountAndFlaggedStorageTransactionMock = vi.fn(); +const withFlaggedStorageTransactionMock = vi.fn(); + +const getCodexCliAuthPathMock = vi.fn(() => "/mock/auth.json"); +const getCodexCliConfigPathMock = vi.fn(() => "/mock/config.toml"); +const loadCodexCliStateMock = vi.fn(); +const setCodexCliActiveSelectionMock = vi.fn(); + +vi.mock("node:fs", () => ({ + existsSync: existsSyncMock, + promises: { + stat: statMock, + readFile: readFileMock, + }, +})); + +vi.mock("../lib/forecast.js", () => ({ + evaluateForecastAccounts: evaluateForecastAccountsMock, + isHardRefreshFailure: vi.fn((result: { reason?: string }) => result.reason === "revoked"), + recommendForecastAccount: recommendForecastAccountMock, +})); + +vi.mock("../lib/accounts.js", () => ({ + extractAccountEmail: extractAccountEmailMock, + extractAccountId: extractAccountIdMock, + formatAccountLabel: formatAccountLabelMock, + sanitizeEmail: sanitizeEmailMock, +})); + +vi.mock("../lib/quota-cache.js", () => ({ + loadQuotaCache: loadQuotaCacheMock, + saveQuotaCache: saveQuotaCacheMock, +})); + +vi.mock("../lib/quota-probe.js", () => ({ + fetchCodexQuotaSnapshot: fetchCodexQuotaSnapshotMock, +})); + +vi.mock("../lib/refresh-queue.js", () => ({ + queuedRefresh: queuedRefreshMock, +})); + +vi.mock("../lib/storage.js", async () => { + const actual = await vi.importActual("../lib/storage.js"); + return { + ...(actual as Record), + loadAccounts: loadAccountsMock, + loadFlaggedAccounts: loadFlaggedAccountsMock, + setStoragePath: setStoragePathMock, + getStoragePath: getStoragePathMock, + withAccountStorageTransaction: withAccountStorageTransactionMock, + withAccountAndFlaggedStorageTransaction: + withAccountAndFlaggedStorageTransactionMock, + withFlaggedStorageTransaction: withFlaggedStorageTransactionMock, + }; +}); + +vi.mock("../lib/codex-cli/state.js", () => ({ + getCodexCliAuthPath: getCodexCliAuthPathMock, + getCodexCliConfigPath: getCodexCliConfigPathMock, + loadCodexCliState: loadCodexCliStateMock, +})); + +vi.mock("../lib/codex-cli/writer.js", () => ({ + setCodexCliActiveSelection: setCodexCliActiveSelectionMock, +})); + +const { + runDoctor, + runFix, + runVerifyFlagged, +} = await import("../lib/codex-manager/repair-commands.js"); + +function createDeps( + overrides: Partial = {}, +): RepairCommandDeps { + return { + stylePromptText: (text) => text, + styleAccountDetailText: (text) => text, + formatResultSummary: (segments) => segments.map((segment) => segment.text).join(" | "), + resolveActiveIndex: () => 0, + hasUsableAccessToken: () => false, + hasLikelyInvalidRefreshToken: () => false, + normalizeFailureDetail: (message, reason) => message ?? reason ?? "unknown", + buildQuotaEmailFallbackState: () => new Map(), + updateQuotaCacheForAccount: () => false, + cloneQuotaCacheData: (cache) => structuredClone(cache), + pruneUnsafeQuotaEmailCacheEntry: () => false, + formatCompactQuotaSnapshot: () => "snapshot-ok", + resolveStoredAccountIdentity: (storedAccountId, storedAccountIdSource, refreshedAccountId) => ({ + accountId: refreshedAccountId ?? storedAccountId, + accountIdSource: refreshedAccountId ? "token" : storedAccountIdSource, + }), + applyTokenAccountIdentity: () => false, + ...overrides, + }; +} + +describe("repair-commands direct deps coverage", () => { + beforeEach(() => { + vi.clearAllMocks(); + existsSyncMock.mockReturnValue(false); + loadQuotaCacheMock.mockResolvedValue(null); + loadCodexCliStateMock.mockResolvedValue(null); + extractAccountEmailMock.mockReturnValue(undefined); + extractAccountIdMock.mockReturnValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("runVerifyFlagged uses the injected identity resolver in the direct no-restore flow", async () => { + const flaggedAccount = { + email: "old@example.com", + refreshToken: "flagged-refresh", + accessToken: "old-access", + expiresAt: 10, + accountId: "stored-account", + accountIdSource: "manual" as const, + lastError: "old-error", + lastUsed: 1, + }; + let persistedFlaggedStorage: unknown; + + loadFlaggedAccountsMock.mockResolvedValue({ + version: 1, + accounts: [structuredClone(flaggedAccount)], + }); + queuedRefreshMock.mockResolvedValue({ + type: "success", + access: "fresh-access", + refresh: "fresh-refresh", + expires: 999, + idToken: "fresh-id-token", + }); + extractAccountEmailMock.mockReturnValue("Recovered@example.com"); + extractAccountIdMock.mockReturnValue("token-account"); + withFlaggedStorageTransactionMock.mockImplementation(async (handler) => + handler( + { version: 1, accounts: [structuredClone(flaggedAccount)] }, + async (nextStorage: unknown) => { + persistedFlaggedStorage = nextStorage; + }, + ), + ); + const resolveStoredAccountIdentity = vi.fn(() => ({ + accountId: "resolved-account", + accountIdSource: "token" as const, + })); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const exitCode = await runVerifyFlagged( + ["--json", "--no-restore"], + createDeps({ resolveStoredAccountIdentity }), + ); + + expect(exitCode).toBe(0); + expect(resolveStoredAccountIdentity).toHaveBeenCalledWith( + "stored-account", + "manual", + "token-account", + ); + expect(withFlaggedStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(persistedFlaggedStorage).toMatchObject({ + version: 1, + accounts: [ + expect.objectContaining({ + accountId: "resolved-account", + accountIdSource: "token", + accessToken: "fresh-access", + refreshToken: "fresh-refresh", + email: "recovered@example.com", + }), + ], + }); + expect( + JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")).reports[0], + ).toMatchObject({ + outcome: "healthy-flagged", + }); + }); + + it("runFix uses the injected token-identity applier in the direct command path", async () => { + const accountStorage = { + version: 3, + accounts: [ + { + email: "old@example.com", + refreshToken: "old-refresh", + accessToken: "old-access", + expiresAt: 0, + accountId: "old-account", + accountIdSource: "manual" as const, + enabled: true, + }, + ], + activeIndex: 0, + activeIndexByFamily: {}, + }; + let persistedAccountStorage: unknown; + + loadAccountsMock.mockResolvedValue(structuredClone(accountStorage)); + queuedRefreshMock.mockResolvedValue({ + type: "success", + access: "new-access", + refresh: "new-refresh", + expires: 5000, + idToken: "new-id-token", + }); + extractAccountEmailMock.mockReturnValue("fresh@example.com"); + extractAccountIdMock.mockReturnValue("token-account"); + withAccountStorageTransactionMock.mockImplementation(async (handler) => + handler(structuredClone(accountStorage), async (nextStorage: unknown) => { + persistedAccountStorage = nextStorage; + }), + ); + const applyTokenAccountIdentity = vi.fn((account: { accountId?: string; accountIdSource?: string }, refreshedAccountId: string | undefined) => { + account.accountId = `dep-${refreshedAccountId}`; + account.accountIdSource = "token"; + return true; + }); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const exitCode = await runFix( + ["--json"], + createDeps({ applyTokenAccountIdentity }), + ); + + expect(exitCode).toBe(0); + expect(applyTokenAccountIdentity).toHaveBeenCalled(); + expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(persistedAccountStorage).toMatchObject({ + accounts: [ + expect.objectContaining({ + accountId: "dep-token-account", + accountIdSource: "token", + accessToken: "new-access", + refreshToken: "new-refresh", + email: "fresh@example.com", + }), + ], + }); + expect( + JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")).summary, + ).toMatchObject({ + healthy: 1, + }); + }); + + it("runDoctor uses the injected refresh-token validator in JSON diagnostics", async () => { + loadAccountsMock.mockResolvedValue({ + version: 3, + accounts: [ + { + email: "doctor@example.com", + refreshToken: "bad-refresh-token", + accessToken: "access", + expiresAt: 100, + accountId: "doctor-account", + accountIdSource: "manual" as const, + enabled: true, + }, + ], + activeIndex: 0, + activeIndexByFamily: {}, + }); + const hasLikelyInvalidRefreshToken = vi.fn(() => true); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const exitCode = await runDoctor( + ["--json"], + createDeps({ hasLikelyInvalidRefreshToken }), + ); + + expect(exitCode).toBe(0); + expect(hasLikelyInvalidRefreshToken).toHaveBeenCalledWith("bad-refresh-token"); + expect( + JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")).checks, + ).toContainEqual( + expect.objectContaining({ + key: "refresh-token-shape", + severity: "warn", + }), + ); + }); +}); From def618d62ed87bfb85170d65f1cb0e4d5c28bd02 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 03:19:13 +0800 Subject: [PATCH 014/376] fix(storage): harden flagged transaction rollback --- lib/storage.ts | 26 +++++- test/codex-manager-cli.test.ts | 66 +++++++++++++++ test/storage.test.ts | 150 +++++++++++++++++++++++++++++++++ 3 files changed, 240 insertions(+), 2 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index f2038246..2db2716e 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -2169,9 +2169,31 @@ export async function withFlaggedStorageTransaction( const current = await loadFlaggedAccountsFromPath(getFlaggedAccountsPath()); let snapshot = cloneFlaggedStorageForPersistence(current); const persist = async (storage: FlaggedAccountStorageV1): Promise => { + const previousStorage = cloneFlaggedStorageForPersistence(snapshot); const nextStorage = cloneFlaggedStorageForPersistence(storage); - await saveFlaggedAccountsUnlocked(nextStorage); - snapshot = nextStorage; + try { + await saveFlaggedAccountsUnlocked(nextStorage); + snapshot = nextStorage; + } catch (error) { + try { + await saveFlaggedAccountsUnlocked(previousStorage); + snapshot = previousStorage; + } catch (rollbackError) { + const combinedError = new AggregateError( + [error, rollbackError], + "Flagged save failed and flagged storage rollback also failed", + ); + log.error( + "Failed to rollback flagged storage after flagged save failure", + { + error: String(error), + rollbackError: String(rollbackError), + }, + ); + throw combinedError; + } + throw error; + } }; return handler(structuredClone(snapshot), persist); }); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index dd67206b..a7765c57 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1726,6 +1726,72 @@ describe("codex manager cli commands", () => { expect(payload.reports[0]?.outcome).toBe("warning-soft-failure"); }); + it("preserves concurrently added accounts during auth fix persistence", async () => { + const now = Date.now(); + const originalAccount = { + email: "alpha@example.com", + accountId: "acc_alpha", + refreshToken: "refresh-alpha", + accessToken: "access-alpha-stale", + expiresAt: now - 5_000, + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }; + const concurrentAccount = { + email: "beta@example.com", + accountId: "acc_beta", + refreshToken: "refresh-beta", + accessToken: "access-beta", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }; + loadAccountsMock + .mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [originalAccount], + }) + .mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [originalAccount, concurrentAccount], + }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "access-alpha-refreshed", + refresh: "refresh-alpha-next", + expires: now + 7_200_000, + idToken: "id-token-alpha", + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "fix", "--json"]); + + expect(exitCode).toBe(0); + expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).toHaveBeenCalledWith( + expect.objectContaining({ + accounts: expect.arrayContaining([ + expect.objectContaining({ + refreshToken: "refresh-alpha-next", + accessToken: "access-alpha-refreshed", + }), + expect.objectContaining({ + accountId: "acc_beta", + refreshToken: "refresh-beta", + accessToken: "access-beta", + }), + ]), + }), + ); + }); + it("does not persist quota cache during auth fix --dry-run --live", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValueOnce({ diff --git a/test/storage.test.ts b/test/storage.test.ts index ccca65c0..674d2425 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -26,6 +26,7 @@ import { setStoragePathDirect, withAccountAndFlaggedStorageTransaction, withAccountStorageTransaction, + withFlaggedStorageTransaction, } from "../lib/storage.js"; // Mocking the behavior we're about to implement for TDD @@ -862,6 +863,155 @@ describe("storage", () => { } }); + it("rolls back flagged storage when flagged-only transaction persistence fails", async () => { + const now = Date.now(); + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + accountId: "acct-flagged", + email: "flagged@example.com", + refreshToken: "refresh-flagged", + addedAt: now - 5_000, + lastUsed: now - 5_000, + flaggedAt: now - 5_000, + }, + ], + }); + + const originalRename = fs.rename.bind(fs); + let flaggedRenameAttempts = 0; + const renameSpy = vi.spyOn(fs, "rename").mockImplementation( + async (from, to) => { + if (String(to).endsWith("openai-codex-flagged-accounts.json")) { + flaggedRenameAttempts += 1; + if (flaggedRenameAttempts <= 5) { + const error = Object.assign( + new Error("flagged storage busy"), + { code: "EBUSY" }, + ); + throw error; + } + } + return originalRename(from, to); + }, + ); + + try { + await expect( + withFlaggedStorageTransaction(async (current, persist) => { + await persist({ + ...current, + accounts: [ + ...current.accounts, + { + accountId: "acct-restored", + email: "restored@example.com", + refreshToken: "refresh-restored", + addedAt: now, + lastUsed: now, + flaggedAt: now, + }, + ], + }); + }), + ).rejects.toThrow("flagged storage busy"); + expect(flaggedRenameAttempts).toBe(6); + } finally { + renameSpy.mockRestore(); + } + + const loadedFlagged = await loadFlaggedAccounts(); + expect(loadedFlagged.accounts).toHaveLength(1); + expect(loadedFlagged.accounts[0]).toEqual( + expect.objectContaining({ + accountId: "acct-flagged", + refreshToken: "refresh-flagged", + }), + ); + }); + + it("passes the live flagged snapshot into account+flagged transactions", async () => { + const now = Date.now(); + await saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + accountId: "acct-existing", + email: "existing@example.com", + refreshToken: "refresh-existing", + addedAt: now - 10_000, + lastUsed: now - 10_000, + }, + ], + }); + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + accountId: "acct-pre-scan", + email: "pre-scan@example.com", + refreshToken: "refresh-pre-scan", + addedAt: now - 5_000, + lastUsed: now - 5_000, + flaggedAt: now - 5_000, + }, + ], + }); + + const preScanFlagged = await loadFlaggedAccounts(); + expect(preScanFlagged.accounts[0]?.refreshToken).toBe("refresh-pre-scan"); + + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + accountId: "acct-live", + email: "live@example.com", + refreshToken: "refresh-live", + addedAt: now - 1_000, + lastUsed: now - 1_000, + flaggedAt: now - 1_000, + }, + ], + }); + + await withAccountAndFlaggedStorageTransaction( + async (current, persist, currentFlagged) => { + expect(current?.accounts).toHaveLength(1); + expect(currentFlagged.accounts).toHaveLength(1); + expect(currentFlagged.accounts[0]?.refreshToken).toBe("refresh-live"); + + currentFlagged.accounts[0]!.refreshToken = "mutated-only"; + + await persist(current!, { + version: 1, + accounts: [ + { + accountId: "acct-persisted", + email: "persisted@example.com", + refreshToken: "refresh-persisted", + addedAt: now, + lastUsed: now, + flaggedAt: now, + }, + ], + }); + }, + ); + + const loadedFlagged = await loadFlaggedAccounts(); + expect(loadedFlagged.accounts).toHaveLength(1); + expect(loadedFlagged.accounts[0]).toEqual( + expect.objectContaining({ + accountId: "acct-persisted", + refreshToken: "refresh-persisted", + }), + ); + }); + it("retries transient flagged storage rename and succeeds", async () => { const now = Date.now(); await saveFlaggedAccounts({ From bf1157b24ca1556f12f7be2951edbe5352bbacd6 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 03:57:29 +0800 Subject: [PATCH 015/376] test: add refactor guardrails --- README.md | 4 +- docs/README.md | 11 ++-- docs/development/RUNBOOK_ADD_AUTH_COMMAND.md | 61 +++++++++++++++++ docs/development/RUNBOOK_ADD_CONFIG_FIELD.md | 65 ++++++++++++++++++ .../RUNBOOK_CHANGE_ROUTING_POLICY.md | 66 +++++++++++++++++++ docs/development/TESTING.md | 24 +++++++ test/documentation.test.ts | 28 ++++++++ test/request-transformer.test.ts | 17 +++++ test/storage.test.ts | 30 +++++++++ 9 files changed, 300 insertions(+), 6 deletions(-) create mode 100644 docs/development/RUNBOOK_ADD_AUTH_COMMAND.md create mode 100644 docs/development/RUNBOOK_ADD_CONFIG_FIELD.md create mode 100644 docs/development/RUNBOOK_CHANGE_ROUTING_POLICY.md diff --git a/README.md b/README.md index f4e5e25f..abb98d54 100644 --- a/README.md +++ b/README.md @@ -291,9 +291,9 @@ codex auth doctor --json ## Release Notes -- Current stable: [docs/releases/v1.2.0.md](docs/releases/v1.2.0.md) -- Previous stable: [docs/releases/v1.1.10.md](docs/releases/v1.1.10.md) +- Current stable: [docs/releases/v1.1.10.md](docs/releases/v1.1.10.md) - Earlier stable: [docs/releases/v0.1.9.md](docs/releases/v0.1.9.md) +- Archived stable: [docs/releases/v0.1.7.md](docs/releases/v0.1.7.md) - Archived prerelease: [docs/releases/v0.1.0-beta.0.md](docs/releases/v0.1.0-beta.0.md) ## License diff --git a/docs/README.md b/docs/README.md index f63480fb..6aba682f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,9 +26,9 @@ Public documentation for `codex-multi-auth`. | [troubleshooting.md](troubleshooting.md) | Recovery playbooks for install, login, switching, and stale state | | [privacy.md](privacy.md) | Data handling and local storage behavior | | [upgrade.md](upgrade.md) | Migration from legacy package and path history | -| [releases/v1.2.0.md](releases/v1.2.0.md) | Stable release notes | -| [releases/v1.1.10.md](releases/v1.1.10.md) | Previous stable release notes | -| [releases/v0.1.9.md](releases/v0.1.9.md) | Earlier stable release notes | +| [releases/v1.1.10.md](releases/v1.1.10.md) | Stable release notes | +| [releases/v0.1.9.md](releases/v0.1.9.md) | Previous stable release notes | +| [releases/v0.1.7.md](releases/v0.1.7.md) | Earlier stable release notes | | [releases/v0.1.7.md](releases/v0.1.7.md) | Archived stable release notes | | [releases/v0.1.6.md](releases/v0.1.6.md) | Archived stable release notes | | [releases/v0.1.5.md](releases/v0.1.5.md) | Archived stable release notes | @@ -45,7 +45,7 @@ Public documentation for `codex-multi-auth`. | [reference/storage-paths.md](reference/storage-paths.md) | Canonical and compatibility storage paths | | [reference/public-api.md](reference/public-api.md) | Public API stability and semver contract | | [reference/error-contracts.md](reference/error-contracts.md) | CLI, JSON, and helper error semantics | -| [releases/v1.2.0.md](releases/v1.2.0.md) | Current stable release notes | +| [releases/v1.1.10.md](releases/v1.1.10.md) | Current stable release notes | | [releases/v0.1.0-beta.0.md](releases/v0.1.0-beta.0.md) | Archived prerelease reference | | [User Guides release notes](#user-guides) | Stable, previous, and archived release notes | | [releases/legacy-pre-0.1-history.md](releases/legacy-pre-0.1-history.md) | Archived pre-0.1 changelog history | @@ -62,6 +62,9 @@ Public documentation for `codex-multi-auth`. | [development/IA_FINDABILITY_AUDIT_2026-03-01.md](development/IA_FINDABILITY_AUDIT_2026-03-01.md) | IA and findability baseline audit | | [development/CONFIG_FIELDS.md](development/CONFIG_FIELDS.md) | Complete field and environment inventory | | [development/CONFIG_FLOW.md](development/CONFIG_FLOW.md) | Configuration resolution flow | +| [development/RUNBOOK_ADD_AUTH_COMMAND.md](development/RUNBOOK_ADD_AUTH_COMMAND.md) | Safe workflow for adding a new `codex auth ...` command | +| [development/RUNBOOK_ADD_CONFIG_FIELD.md](development/RUNBOOK_ADD_CONFIG_FIELD.md) | Safe workflow for introducing a new config field | +| [development/RUNBOOK_CHANGE_ROUTING_POLICY.md](development/RUNBOOK_CHANGE_ROUTING_POLICY.md) | Safe workflow for changing routing, retry, or fallback policy | | [development/REPOSITORY_SCOPE.md](development/REPOSITORY_SCOPE.md) | Ownership map by repository path | | [development/TESTING.md](development/TESTING.md) | Validation gates and test matrix | | [development/TUI_PARITY_CHECKLIST.md](development/TUI_PARITY_CHECKLIST.md) | Dashboard UX parity checklist | diff --git a/docs/development/RUNBOOK_ADD_AUTH_COMMAND.md b/docs/development/RUNBOOK_ADD_AUTH_COMMAND.md new file mode 100644 index 00000000..c4b27cfd --- /dev/null +++ b/docs/development/RUNBOOK_ADD_AUTH_COMMAND.md @@ -0,0 +1,61 @@ +# Runbook: Add Auth Command + +Safe workflow for adding a new `codex auth ...` command without expanding scope or breaking the existing CLI contract. + +* * * + +## Goal + +Add one new command path while keeping: + +- `codex auth ...` as the canonical command family +- current help text and aliases aligned with docs +- JSON and human-readable output predictable +- command behavior covered by targeted tests + +* * * + +## Primary Files + +- `lib/codex-manager.ts` +- `docs/reference/commands.md` +- `README.md` when user-visible workflow changes +- `test/codex-manager-cli.test.ts` +- `test/documentation.test.ts` + +* * * + +## Implementation Steps + +1. Add the command logic in `lib/codex-manager.ts` or the current command handler module. +2. Keep usage text literal and copy-pasteable. +3. Reuse existing storage, refresh, and quota helpers instead of adding new command-local state. +4. Add or extend CLI tests in `test/codex-manager-cli.test.ts` for: + - success path + - invalid input or missing args + - JSON mode if supported + - non-interactive behavior if relevant +5. Update `docs/reference/commands.md` with the command and flags. +6. Update `README.md` only when the command changes the recommended user workflow. +7. Update `test/documentation.test.ts` if new command text must stay aligned across docs and runtime usage text. + +* * * + +## Validation + +```bash +npm run lint +npm run typecheck +npm test -- test/codex-manager-cli.test.ts test/documentation.test.ts +npm run build +``` + +* * * + +## Review Checklist + +- command name is consistent across runtime and docs +- help text matches actual flags +- no unrelated settings or storage changes were mixed in +- JSON output is stable if exposed +- tests cover failure paths, not only the happy path diff --git a/docs/development/RUNBOOK_ADD_CONFIG_FIELD.md b/docs/development/RUNBOOK_ADD_CONFIG_FIELD.md new file mode 100644 index 00000000..929d25bc --- /dev/null +++ b/docs/development/RUNBOOK_ADD_CONFIG_FIELD.md @@ -0,0 +1,65 @@ +# Runbook: Add Config Field + +Checklist for adding a new configuration field while preserving precedence, migration expectations, and documentation parity. + +* * * + +## Goal + +Add one field with a clear source of truth and keep config behavior explainable. + +* * * + +## Primary Files + +- `lib/config.ts` +- `docs/configuration.md` +- `docs/development/CONFIG_FIELDS.md` +- `docs/development/CONFIG_FLOW.md` +- `test/config.test.ts` +- `test/plugin-config.test.ts` +- `test/documentation.test.ts` + +* * * + +## Implementation Steps + +1. Add the field in `lib/config.ts` with an explicit default. +2. Decide whether it is: + - stable user-facing + - advanced + - internal only +3. Keep precedence explicit: + - config file source + - fallback config source when applicable + - environment override layer +4. Add tests for: + - default resolution + - config file resolution + - environment override behavior + - invalid value handling when relevant +5. Update `docs/configuration.md` with user-facing guidance. +6. Update `docs/development/CONFIG_FIELDS.md` with field inventory details. +7. Update `docs/development/CONFIG_FLOW.md` when source selection or precedence changes. +8. Extend `test/documentation.test.ts` if docs parity should remain locked. + +* * * + +## Validation + +```bash +npm run lint +npm run typecheck +npm test -- test/config.test.ts test/plugin-config.test.ts test/documentation.test.ts +npm run build +``` + +* * * + +## Review Checklist + +- field has one documented default +- precedence is documented and tested +- environment variable naming is consistent +- user docs and maintainer docs agree +- no hidden migration behavior was introduced diff --git a/docs/development/RUNBOOK_CHANGE_ROUTING_POLICY.md b/docs/development/RUNBOOK_CHANGE_ROUTING_POLICY.md new file mode 100644 index 00000000..fa104f5e --- /dev/null +++ b/docs/development/RUNBOOK_CHANGE_ROUTING_POLICY.md @@ -0,0 +1,66 @@ +# Runbook: Change Routing Policy + +Safe workflow for changing account selection, fallback, retry, or failover behavior in the plugin runtime. + +* * * + +## Goal + +Adjust routing policy without obscuring why requests changed behavior. + +* * * + +## Primary Files + +- `index.ts` +- `lib/request/failure-policy.ts` +- `lib/request/rate-limit-backoff.ts` +- `lib/request/stream-failover.ts` +- `lib/request/request-transformer.ts` +- `lib/accounts.ts` +- `lib/rotation.ts` +- `test/index.test.ts` +- `test/index-retry.test.ts` +- `test/failure-policy.test.ts` +- `test/request-transformer.test.ts` +- `test/stream-failover.test.ts` + +* * * + +## Implementation Steps + +1. Write down the policy change in one sentence before coding. +2. Identify whether the change affects: + - account choice + - fallback model choice + - retry timing + - cooldown timing + - stream failover behavior +3. Add or update the narrowest tests first. +4. Preserve request invariants unless the change explicitly targets them: + - `stream: true` + - `store: false` + - include `reasoning.encrypted_content` +5. Prefer adjusting one policy decision point instead of rewriting multiple layers at once. +6. If behavior becomes harder to explain, add diagnostics or comments before merging. + +* * * + +## Validation + +```bash +npm run lint +npm run typecheck +npm test -- test/index.test.ts test/index-retry.test.ts test/failure-policy.test.ts test/request-transformer.test.ts test/stream-failover.test.ts +npm run build +``` + +* * * + +## Review Checklist + +- policy delta is clearly stated +- request invariants remain covered +- retry or fallback changes have targeted regression tests +- reviewers can tell whether behavior changed intentionally or accidentally +- no storage or CLI refactor was mixed into the same change diff --git a/docs/development/TESTING.md b/docs/development/TESTING.md index 9292b906..b754e8c2 100644 --- a/docs/development/TESTING.md +++ b/docs/development/TESTING.md @@ -95,6 +95,30 @@ Optional plugin-host smoke: * * * +## Refactor Guardrail Checklist + +Before approving a large runtime, manager, or storage refactor, run the narrow suites that protect the highest-risk invariants: + +```bash +npm test -- test/index.test.ts test/index-retry.test.ts +npm test -- test/codex-manager-cli.test.ts +npm test -- test/storage.test.ts test/storage-async.test.ts test/storage-recovery-paths.test.ts test/paths.test.ts +``` + +Key guardrails to watch: + +- request invariants stay locked: `stream: true`, `store: false`, and `reasoning.encrypted_content` +- storage failures still produce actionable `StorageError` hints +- linked-worktree and forged-path protections remain covered by `test/paths.test.ts` + +Runbooks for common maintenance tasks: + +- [RUNBOOK_ADD_AUTH_COMMAND.md](RUNBOOK_ADD_AUTH_COMMAND.md) +- [RUNBOOK_ADD_CONFIG_FIELD.md](RUNBOOK_ADD_CONFIG_FIELD.md) +- [RUNBOOK_CHANGE_ROUTING_POLICY.md](RUNBOOK_CHANGE_ROUTING_POLICY.md) + +* * * + ## Docs QA (when docs change) 1. Verify every command snippet is runnable. diff --git a/test/documentation.test.ts b/test/documentation.test.ts index 1c696d36..f83fbf18 100644 --- a/test/documentation.test.ts +++ b/test/documentation.test.ts @@ -56,6 +56,12 @@ const compatibilityAliasAllowedFiles = new Set([ "docs/upgrade.md", ]); +const maintainerRunbooks = [ + "docs/development/RUNBOOK_ADD_AUTH_COMMAND.md", + "docs/development/RUNBOOK_ADD_CONFIG_FIELD.md", + "docs/development/RUNBOOK_CHANGE_ROUTING_POLICY.md", +]; + function read(filePath: string): string { return readFileSync(join(projectRoot, filePath), "utf-8"); } @@ -456,6 +462,28 @@ describe("Documentation Integrity", () => { expect(conduct).toContain("security.md"); }); + it("publishes maintainer runbooks for refactor-era changes", () => { + const docsPortal = read("docs/README.md"); + const testingGuide = read("docs/development/TESTING.md"); + + for (const filePath of maintainerRunbooks) { + expect(existsSync(join(projectRoot, filePath)), `${filePath} should exist`).toBe( + true, + ); + const content = read(filePath).toLowerCase(); + expect(content).toContain("validation"); + expect(content).toContain("review checklist"); + } + + expect(docsPortal).toContain("development/RUNBOOK_ADD_AUTH_COMMAND.md"); + expect(docsPortal).toContain("development/RUNBOOK_ADD_CONFIG_FIELD.md"); + expect(docsPortal).toContain("development/RUNBOOK_CHANGE_ROUTING_POLICY.md"); + expect(testingGuide).toContain("## Refactor Guardrail Checklist"); + expect(testingGuide).toContain("stream: true"); + expect(testingGuide).toContain("store: false"); + expect(testingGuide).toContain("reasoning.encrypted_content"); + }); + it("has valid internal links in README.md", () => { const content = read("README.md"); const links = extractInternalLinks(content); diff --git a/test/request-transformer.test.ts b/test/request-transformer.test.ts index b53ca89b..6de3bbbd 100644 --- a/test/request-transformer.test.ts +++ b/test/request-transformer.test.ts @@ -1302,9 +1302,26 @@ describe('Request Transformer Module', () => { input: [], }; const result = await transformRequestBody(body, codexInstructions); + expect(result.stream).toBe(true); + expect(result.store).toBe(false); expect(result.include).toEqual(['reasoning.encrypted_content']); }); + it('should force stateless request invariants even when caller sets conflicting values', async () => { + const body: RequestBody = { + model: 'gpt-5', + input: [], + stream: false, + store: true, + include: ['custom_field'], + }; + const result = await transformRequestBody(body, codexInstructions); + + expect(result.stream).toBe(true); + expect(result.store).toBe(false); + expect(result.include).toEqual(['custom_field', 'reasoning.encrypted_content']); + }); + it('should use user-configured include', async () => { const body: RequestBody = { model: 'gpt-5', diff --git a/test/storage.test.ts b/test/storage.test.ts index ccca65c0..e69feac2 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -49,6 +49,36 @@ describe("storage", () => { process.env.CODEX_MULTI_AUTH_DIR = _origCODEX_MULTI_AUTH_DIR; else delete process.env.CODEX_MULTI_AUTH_DIR; }); + + describe("storage error hints", () => { + it("formats actionable Windows file-lock guidance for EBUSY errors", () => { + const hint = formatStorageErrorHint( + { code: "EBUSY" }, + "C:/Users/example/.codex/multi-auth/openai-codex-accounts.json", + ); + + expect(hint).toContain("File is locked"); + expect(hint).toContain("open in another program"); + }); + + it("preserves the original cause and hint on StorageError", () => { + const cause = Object.assign(new Error("permission denied"), { + code: "EPERM", + }); + const hint = formatStorageErrorHint(cause, "/tmp/openai-codex-accounts.json"); + const error = new StorageError( + "failed to persist accounts", + "EPERM", + "/tmp/openai-codex-accounts.json", + hint, + cause, + ); + + expect(error.cause).toBe(cause); + expect(error.hint).toContain("Permission denied writing"); + expect(error.path).toBe("/tmp/openai-codex-accounts.json"); + }); + }); describe("deduplication", () => { it("preserves activeIndexByFamily when shared accountId entries remain distinct without email", () => { const now = Date.now(); From 585ee57a66be00cd1ba3c3479b8823b17182c7ea Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 04:05:43 +0800 Subject: [PATCH 016/376] refactor: extract switch command handler --- lib/codex-manager.ts | 1377 ++++++++++++++------- lib/codex-manager/commands/switch.ts | 78 ++ test/codex-manager-switch-command.test.ts | 94 ++ 3 files changed, 1076 insertions(+), 473 deletions(-) create mode 100644 lib/codex-manager/commands/switch.ts create mode 100644 test/codex-manager-switch-command.test.ts diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index b80f1204..7f1be092 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -1,16 +1,7 @@ -import { createInterface } from "node:readline/promises"; -import { stdin as input, stdout as output } from "node:process"; -import { promises as fs, existsSync } from "node:fs"; +import { existsSync, promises as fs } from "node:fs"; import { dirname, resolve } from "node:path"; -import { - createAuthorizationFlow, - exchangeAuthorizationCode, - parseAuthorizationInput, - REDIRECT_URI, -} from "./auth/auth.js"; -import { startLocalOAuthServer } from "./auth/server.js"; -import { copyTextToClipboard, isBrowserLaunchSuppressed, openBrowserUrl } from "./auth/browser.js"; -import { promptAddAnotherAccount, promptLoginMode, type ExistingAccountInfo } from "./cli.js"; +import { stdin as input, stdout as output } from "node:process"; +import { createInterface } from "node:readline/promises"; import { extractAccountEmail, extractAccountId, @@ -23,68 +14,90 @@ import { selectBestAccountCandidate, shouldUpdateAccountIdFromToken, } from "./accounts.js"; +import { + createAuthorizationFlow, + exchangeAuthorizationCode, + parseAuthorizationInput, + REDIRECT_URI, +} from "./auth/auth.js"; +import { + copyTextToClipboard, + isBrowserLaunchSuppressed, + openBrowserUrl, +} from "./auth/browser.js"; +import { startLocalOAuthServer } from "./auth/server.js"; +import { + type ExistingAccountInfo, + promptAddAnotherAccount, + promptLoginMode, +} from "./cli.js"; +import { + getCodexCliAuthPath, + getCodexCliConfigPath, + loadCodexCliState, +} from "./codex-cli/state.js"; +import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; +import { runSwitchCommand } from "./codex-manager/commands/switch.js"; +import { + applyUiThemeFromDashboardSettings, + configureUnifiedSettings, + resolveMenuLayoutMode, +} from "./codex-manager/settings-hub.js"; import { ACCOUNT_LIMITS } from "./constants.js"; import { - loadDashboardDisplaySettings, - DEFAULT_DASHBOARD_DISPLAY_SETTINGS, - type DashboardDisplaySettings, type DashboardAccountSortMode, + type DashboardDisplaySettings, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS, + loadDashboardDisplaySettings, } from "./dashboard-settings.js"; import { evaluateForecastAccounts, + type ForecastAccountResult, isHardRefreshFailure, recommendForecastAccount, summarizeForecast, - type ForecastAccountResult, } from "./forecast.js"; import { createLogger } from "./logger.js"; import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; -import { - fetchCodexQuotaSnapshot, - formatQuotaSnapshotLine, - type CodexQuotaSnapshot, -} from "./quota-probe.js"; -import { queuedRefresh } from "./refresh-queue.js"; import { loadQuotaCache, - saveQuotaCache, type QuotaCacheData, type QuotaCacheEntry, + saveQuotaCache, } from "./quota-cache.js"; import { + type CodexQuotaSnapshot, + fetchCodexQuotaSnapshot, + formatQuotaSnapshotLine, +} from "./quota-probe.js"; +import { queuedRefresh } from "./refresh-queue.js"; +import { + type AccountMetadataV3, + type AccountStorageV3, clearAccounts, + type FlaggedAccountMetadataV1, findMatchingAccountIndex, formatStorageErrorHint, getNamedBackups, getStoragePath, - loadFlaggedAccounts, loadAccounts, - StorageError, + loadFlaggedAccounts, type NamedBackupSummary, restoreAccountsFromBackup, - saveFlaggedAccounts, + StorageError, saveAccounts, + saveFlaggedAccounts, setStoragePath, withAccountAndFlaggedStorageTransaction, withAccountStorageTransaction, - type AccountMetadataV3, - type AccountStorageV3, - type FlaggedAccountMetadataV1, } from "./storage.js"; import type { AccountIdSource, TokenFailure, TokenResult } from "./types.js"; -import { - getCodexCliAuthPath, - getCodexCliConfigPath, - loadCodexCliState, -} from "./codex-cli/state.js"; -import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; import { ANSI } from "./ui/ansi.js"; -import { UI_COPY } from "./ui/copy.js"; import { confirm } from "./ui/confirm.js"; +import { UI_COPY } from "./ui/copy.js"; import { paintUiText, quotaToneFromLeftPercent } from "./ui/format.js"; import { getUiRuntimeOptions } from "./ui/runtime.js"; -import { select, type MenuItem } from "./ui/select.js"; -import { applyUiThemeFromDashboardSettings, configureUnifiedSettings, resolveMenuLayoutMode } from "./codex-manager/settings-hub.js"; +import { type MenuItem, select } from "./ui/select.js"; type TokenSuccess = Extract; type TokenSuccessWithAccount = TokenSuccess & { @@ -105,15 +118,16 @@ function stylePromptText(text: string, tone: PromptTone): string { const mapped = tone === "accent" ? "primary" : tone; return paintUiText(ui, text, mapped); } - const legacyCode = tone === "accent" - ? ANSI.green - : tone === "success" + const legacyCode = + tone === "accent" ? ANSI.green - : tone === "warning" - ? ANSI.yellow - : tone === "danger" - ? ANSI.red - : ANSI.dim; + : tone === "success" + ? ANSI.green + : tone === "warning" + ? ANSI.yellow + : tone === "danger" + ? ANSI.red + : ANSI.dim; return `${legacyCode}${text}${ANSI.reset}`; } @@ -131,14 +145,17 @@ function extractErrorMessageFromPayload(payload: unknown): string | undefined { if (!payload || typeof payload !== "object") return undefined; const record = payload as Record; - const directMessage = typeof record.message === "string" - ? collapseWhitespace(record.message) - : ""; - const directCode = typeof record.code === "string" - ? collapseWhitespace(record.code) - : ""; + const directMessage = + typeof record.message === "string" + ? collapseWhitespace(record.message) + : ""; + const directCode = + typeof record.code === "string" ? collapseWhitespace(record.code) : ""; if (directMessage) { - if (directCode && !directMessage.toLowerCase().includes(directCode.toLowerCase())) { + if ( + directCode && + !directMessage.toLowerCase().includes(directCode.toLowerCase()) + ) { return `${directMessage} [${directCode}]`; } return directMessage; @@ -181,7 +198,8 @@ function normalizeFailureDetail( const raw = message?.trim() || reasonLabel || "refresh failed"; const structured = parseStructuredErrorMessage(raw); const normalized = collapseWhitespace(structured ?? raw); - const bounded = normalized.length > 260 ? `${normalized.slice(0, 257)}...` : normalized; + const bounded = + normalized.length > 260 ? `${normalized.slice(0, 257)}...` : normalized; return bounded.length > 0 ? bounded : "refresh failed"; } @@ -194,14 +212,19 @@ function joinStyledSegments(parts: string[]): string { function formatResultSummary( segments: ReadonlyArray<{ text: string; tone: PromptTone }>, ): string { - const rendered = segments.map((segment) => stylePromptText(segment.text, segment.tone)); + const rendered = segments.map((segment) => + stylePromptText(segment.text, segment.tone), + ); return `${stylePromptText("Result:", "accent")} ${joinStyledSegments(rendered)}`; } function styleQuotaSummary(summary: string): string { const normalized = collapseWhitespace(summary); if (!normalized) return stylePromptText(summary, "muted"); - const segments = normalized.split("|").map((segment) => segment.trim()).filter(Boolean); + const segments = normalized + .split("|") + .map((segment) => segment.trim()) + .filter(Boolean); if (segments.length === 0) return stylePromptText(normalized, "muted"); const rendered = segments.map((segment) => { @@ -224,7 +247,10 @@ function styleQuotaSummary(summary: string): string { return joinStyledSegments(rendered); } -function styleAccountDetailText(detail: string, fallbackTone: PromptTone = "muted"): string { +function styleAccountDetailText( + detail: string, + fallbackTone: PromptTone = "muted", +): string { const compact = collapseWhitespace(detail); if (!compact) return stylePromptText("", fallbackTone); @@ -239,11 +265,12 @@ function styleAccountDetailText(detail: string, fallbackTone: PromptTone = "mute : /ok|working|succeeded|valid/i.test(prefix) ? "success" : fallbackTone; - const suffixTone: PromptTone = /re-login|stale|warning|retry|fallback/i.test(suffix) - ? "warning" - : /failed|error/i.test(suffix) - ? "danger" - : "muted"; + const suffixTone: PromptTone = + /re-login|stale|warning|retry|fallback/i.test(suffix) + ? "warning" + : /failed|error/i.test(suffix) + ? "danger" + : "muted"; const chunks: string[] = []; if (prefix) chunks.push(stylePromptText(prefix, prefixTone)); @@ -253,13 +280,17 @@ function styleAccountDetailText(detail: string, fallbackTone: PromptTone = "mute } if (/rate-limited/i.test(compact)) return stylePromptText(compact, "danger"); - if (/re-login|stale|warning|fallback/i.test(compact)) return stylePromptText(compact, "warning"); + if (/re-login|stale|warning|fallback/i.test(compact)) + return stylePromptText(compact, "warning"); if (/failed|error/i.test(compact)) return stylePromptText(compact, "danger"); - if (/ok|working|succeeded|valid/i.test(compact)) return stylePromptText(compact, "success"); + if (/ok|working|succeeded|valid/i.test(compact)) + return stylePromptText(compact, "success"); return stylePromptText(compact, fallbackTone); } -function riskTone(level: ForecastAccountResult["riskLevel"]): "success" | "warning" | "danger" { +function riskTone( + level: ForecastAccountResult["riskLevel"], +): "success" | "warning" | "danger" { if (level === "low") return "success"; if (level === "medium") return "warning"; return "danger"; @@ -414,7 +445,8 @@ function resolveActiveIndex( ): number { const total = storage.accounts.length; if (total === 0) return 0; - const rawCandidate = storage.activeIndexByFamily?.[family] ?? storage.activeIndex; + const rawCandidate = + storage.activeIndexByFamily?.[family] ?? storage.activeIndex; const raw = Number.isFinite(rawCandidate) ? rawCandidate : 0; return Math.max(0, Math.min(raw, total - 1)); } @@ -545,7 +577,9 @@ function quotaCacheEntryToSnapshot(entry: QuotaCacheEntry): CodexQuotaSnapshot { }; } -function formatCompactQuotaWindowLabel(windowMinutes: number | undefined): string { +function formatCompactQuotaWindowLabel( + windowMinutes: number | undefined, +): string { if (!windowMinutes || !Number.isFinite(windowMinutes) || windowMinutes <= 0) { return "quota"; } @@ -554,7 +588,10 @@ function formatCompactQuotaWindowLabel(windowMinutes: number | undefined): strin return `${windowMinutes}m`; } -function formatCompactQuotaPart(windowMinutes: number | undefined, usedPercent: number | undefined): string | null { +function formatCompactQuotaPart( + windowMinutes: number | undefined, + usedPercent: number | undefined, +): string | null { const label = formatCompactQuotaWindowLabel(windowMinutes); if (typeof usedPercent !== "number" || !Number.isFinite(usedPercent)) { return null; @@ -563,7 +600,9 @@ function formatCompactQuotaPart(windowMinutes: number | undefined, usedPercent: return `${label} ${left}%`; } -function quotaLeftPercentFromUsed(usedPercent: number | undefined): number | undefined { +function quotaLeftPercentFromUsed( + usedPercent: number | undefined, +): number | undefined { if (typeof usedPercent !== "number" || !Number.isFinite(usedPercent)) { return undefined; } @@ -572,9 +611,17 @@ function quotaLeftPercentFromUsed(usedPercent: number | undefined): number | und function formatCompactQuotaSnapshot(snapshot: CodexQuotaSnapshot): string { const parts = [ - formatCompactQuotaPart(snapshot.primary.windowMinutes, snapshot.primary.usedPercent), - formatCompactQuotaPart(snapshot.secondary.windowMinutes, snapshot.secondary.usedPercent), - ].filter((value): value is string => typeof value === "string" && value.length > 0); + formatCompactQuotaPart( + snapshot.primary.windowMinutes, + snapshot.primary.usedPercent, + ), + formatCompactQuotaPart( + snapshot.secondary.windowMinutes, + snapshot.secondary.usedPercent, + ), + ].filter( + (value): value is string => typeof value === "string" && value.length > 0, + ); if (snapshot.status === 429) { parts.push("rate-limited"); } @@ -586,9 +633,17 @@ function formatCompactQuotaSnapshot(snapshot: CodexQuotaSnapshot): string { function formatAccountQuotaSummary(entry: QuotaCacheEntry): string { const parts = [ - formatCompactQuotaPart(entry.primary.windowMinutes, entry.primary.usedPercent), - formatCompactQuotaPart(entry.secondary.windowMinutes, entry.secondary.usedPercent), - ].filter((value): value is string => typeof value === "string" && value.length > 0); + formatCompactQuotaPart( + entry.primary.windowMinutes, + entry.primary.usedPercent, + ), + formatCompactQuotaPart( + entry.secondary.windowMinutes, + entry.secondary.usedPercent, + ), + ].filter( + (value): value is string => typeof value === "string" && value.length > 0, + ); if (entry.status === 429) { parts.push("rate-limited"); } @@ -865,11 +920,17 @@ function hasUsableAccessToken( now: number, ): boolean { if (!account.accessToken) return false; - if (typeof account.expiresAt !== "number" || !Number.isFinite(account.expiresAt)) return false; + if ( + typeof account.expiresAt !== "number" || + !Number.isFinite(account.expiresAt) + ) + return false; return account.expiresAt - now > ACCESS_TOKEN_FRESH_WINDOW_MS; } -function hasLikelyInvalidRefreshToken(refreshToken: string | undefined): boolean { +function hasLikelyInvalidRefreshToken( + refreshToken: string | undefined, +): boolean { if (!refreshToken) return true; const trimmed = refreshToken.trim(); if (trimmed.length < 20) return true; @@ -883,7 +944,10 @@ function mapAccountStatus( now: number, ): ExistingAccountInfo["status"] { if (account.enabled === false) return "disabled"; - if (typeof account.coolingDownUntil === "number" && account.coolingDownUntil > now) { + if ( + typeof account.coolingDownUntil === "number" && + account.coolingDownUntil > now + ) { return "cooldown"; } const rateLimit = formatRateLimitEntry(account, now, "codex"); @@ -897,7 +961,9 @@ function parseLeftPercentFromQuotaSummary( windowLabel: "5h" | "7d", ): number { if (!summary) return -1; - const match = summary.match(new RegExp(`(?:^|\\|)\\s*${windowLabel}\\s+(\\d{1,3})%`, "i")); + const match = summary.match( + new RegExp(`(?:^|\\|)\\s*${windowLabel}\\s+(\\d{1,3})%`, "i"), + ); const value = Number.parseInt(match?.[1] ?? "", 10); if (!Number.isFinite(value)) return -1; return Math.max(0, Math.min(100, value)); @@ -907,14 +973,19 @@ function readQuotaLeftPercent( account: ExistingAccountInfo, windowLabel: "5h" | "7d", ): number { - const direct = windowLabel === "5h" ? account.quota5hLeftPercent : account.quota7dLeftPercent; + const direct = + windowLabel === "5h" + ? account.quota5hLeftPercent + : account.quota7dLeftPercent; if (typeof direct === "number" && Number.isFinite(direct)) { return Math.max(0, Math.min(100, Math.round(direct))); } return parseLeftPercentFromQuotaSummary(account.quotaSummary, windowLabel); } -function accountStatusSortBucket(status: ExistingAccountInfo["status"]): number { +function accountStatusSortBucket( + status: ExistingAccountInfo["status"], +): number { switch (status) { case "active": case "ok": @@ -945,7 +1016,9 @@ function compareReadyFirstAccounts( const right7d = readQuotaLeftPercent(right, "7d"); if (left7d !== right7d) return right7d - left7d; - const bucketDelta = accountStatusSortBucket(left.status) - accountStatusSortBucket(right.status); + const bucketDelta = + accountStatusSortBucket(left.status) - + accountStatusSortBucket(right.status); if (bucketDelta !== 0) return bucketDelta; const leftLastUsed = left.lastUsed ?? 0; @@ -962,18 +1035,26 @@ function applyAccountMenuOrdering( displaySettings: DashboardDisplaySettings, ): ExistingAccountInfo[] { const sortEnabled = - displaySettings.menuSortEnabled ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? true); + displaySettings.menuSortEnabled ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? + true; const sortMode: DashboardAccountSortMode = - displaySettings.menuSortMode ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? "ready-first"); + displaySettings.menuSortMode ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? + "ready-first"; if (!sortEnabled || sortMode !== "ready-first") { return [...accounts]; } const sorted = [...accounts].sort(compareReadyFirstAccounts); - const pinCurrent = displaySettings.menuSortPinCurrent ?? - (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortPinCurrent ?? false); + const pinCurrent = + displaySettings.menuSortPinCurrent ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortPinCurrent ?? + false; if (pinCurrent) { - const currentIndex = sorted.findIndex((account) => account.isCurrentAccount); + const currentIndex = sorted.findIndex( + (account) => account.isCurrentAccount, + ); if (currentIndex > 0) { const current = sorted.splice(currentIndex, 1)[0]; const first = sorted[0]; @@ -1014,12 +1095,15 @@ function toExistingAccountInfo( addedAt: account.addedAt, lastUsed: account.lastUsed, status: mapAccountStatus(account, index, activeIndex, now), - quotaSummary: (displaySettings.menuShowQuotaSummary ?? true) && entry - ? formatAccountQuotaSummary(entry) - : undefined, + quotaSummary: + (displaySettings.menuShowQuotaSummary ?? true) && entry + ? formatAccountQuotaSummary(entry) + : undefined, quota5hLeftPercent: quotaLeftPercentFromUsed(entry?.primary.usedPercent), quota5hResetAtMs: entry?.primary.resetAtMs, - quota7dLeftPercent: quotaLeftPercentFromUsed(entry?.secondary.usedPercent), + quota7dLeftPercent: quotaLeftPercentFromUsed( + entry?.secondary.usedPercent, + ), quota7dResetAtMs: entry?.secondary.resetAtMs, quotaRateLimited: entry?.status === 429, isCurrentAccount: index === activeIndex, @@ -1031,11 +1115,19 @@ function toExistingAccountInfo( showHintsForUnselectedRows: layoutMode === "expanded-rows", highlightCurrentRow: displaySettings.menuHighlightCurrentRow ?? true, focusStyle: displaySettings.menuFocusStyle ?? "row-invert", - statuslineFields: displaySettings.menuStatuslineFields ?? ["last-used", "limits", "status"], + statuslineFields: displaySettings.menuStatuslineFields ?? [ + "last-used", + "limits", + "status", + ], }; }); - const orderedAccounts = applyAccountMenuOrdering(baseAccounts, displaySettings); - const quickSwitchUsesVisibleRows = displaySettings.menuSortQuickSwitchVisibleRow ?? true; + const orderedAccounts = applyAccountMenuOrdering( + baseAccounts, + displaySettings, + ); + const quickSwitchUsesVisibleRows = + displaySettings.menuSortQuickSwitchVisibleRow ?? true; return orderedAccounts.map((account, displayIndex) => ({ ...account, index: displayIndex, @@ -1045,7 +1137,9 @@ function toExistingAccountInfo( })); } -function resolveAccountSelection(tokens: TokenSuccess): TokenSuccessWithAccount { +function resolveAccountSelection( + tokens: TokenSuccess, +): TokenSuccessWithAccount { const override = (process.env.CODEX_AUTH_ACCOUNT_ID ?? "").trim(); if (override) { return { @@ -1108,7 +1202,8 @@ function resolveStoredAccountIdentity( return { accountId, - accountIdSource: accountId === tokenAccountId ? "token" : storedAccountIdSource, + accountIdSource: + accountId === tokenAccountId ? "token" : storedAccountIdSource, }; } @@ -1124,8 +1219,10 @@ function applyTokenAccountIdentity( if (!nextIdentity.accountId) { return false; } - if (nextIdentity.accountId === account.accountId - && nextIdentity.accountIdSource === account.accountIdSource) { + if ( + nextIdentity.accountId === account.accountId && + nextIdentity.accountIdSource === account.accountIdSource + ) { return false; } @@ -1228,7 +1325,10 @@ function isReadlineClosedError(error: unknown): boolean { typeof error === "object" && error !== null && "code" in error ? String((error as { code?: unknown }).code) : ""; - return errorCode === "ERR_USE_AFTER_CLOSE" || /readline was closed/i.test(error.message); + return ( + errorCode === "ERR_USE_AFTER_CLOSE" || + /readline was closed/i.test(error.message) + ); } type OAuthSignInMode = "browser" | "manual" | "restore-backup" | "cancel"; @@ -1254,24 +1354,32 @@ async function promptOAuthSignInMode( const ui = getUiRuntimeOptions(); const items: MenuItem[] = [ - { label: UI_COPY.oauth.signInHeading, value: "cancel" as const, kind: "heading" }, + { + label: UI_COPY.oauth.signInHeading, + value: "cancel" as const, + kind: "heading", + }, { label: UI_COPY.oauth.openBrowser, value: "browser", color: "green" }, { label: UI_COPY.oauth.manualMode, value: "manual", color: "yellow" }, ...(backupOption ? [ - { separator: true, label: "", value: "cancel" as const }, - { label: UI_COPY.oauth.restoreHeading, value: "cancel" as const, kind: "heading" as const }, - { - label: UI_COPY.oauth.restoreSavedBackup, - value: "restore-backup" as const, - hint: UI_COPY.oauth.loadLastBackupHint( - backupOption.fileName, - backupOption.accountCount, - formatBackupSavedAt(backupOption.mtimeMs), - ), - color: "cyan" as const, - }, - ] + { separator: true, label: "", value: "cancel" as const }, + { + label: UI_COPY.oauth.restoreHeading, + value: "cancel" as const, + kind: "heading" as const, + }, + { + label: UI_COPY.oauth.restoreSavedBackup, + value: "restore-backup" as const, + hint: UI_COPY.oauth.loadLastBackupHint( + backupOption.fileName, + backupOption.accountCount, + formatBackupSavedAt(backupOption.mtimeMs), + ), + color: "cyan" as const, + }, + ] : []), { separator: true, label: "", value: "cancel" as const }, { label: UI_COPY.oauth.back, value: "cancel", color: "red" }, @@ -1356,15 +1464,17 @@ async function promptManualBackupSelection( } const ui = getUiRuntimeOptions(); - const items: MenuItem[] = backups.map((backup) => ({ - label: backup.fileName, - value: backup, - hint: UI_COPY.oauth.manualBackupHint( - backup.accountCount, - formatBackupSavedAt(backup.mtimeMs), - ), - color: "cyan", - })); + const items: MenuItem[] = backups.map( + (backup) => ({ + label: backup.fileName, + value: backup, + hint: UI_COPY.oauth.manualBackupHint( + backup.accountCount, + formatBackupSavedAt(backup.mtimeMs), + ), + color: "cyan", + }), + ); items.push({ label: UI_COPY.oauth.back, value: null, color: "red" }); const selected = await select(items, { @@ -1390,7 +1500,9 @@ interface WaitForReturnOptions { pauseOnAnyKey?: boolean; } -async function waitForMenuReturn(options: WaitForReturnOptions = {}): Promise { +async function waitForMenuReturn( + options: WaitForReturnOptions = {}, +): Promise { if (!input.isTTY || !output.isTTY) { return; } @@ -1429,9 +1541,7 @@ async function waitForMenuReturn(options: WaitForReturnOptions = {}): Promise((resolve) => { @@ -1506,7 +1616,8 @@ async function waitForMenuReturn(options: WaitForReturnOptions = {}): Promise 0 ? `${stylePromptText(promptText, "muted")} ` : ""; + const question = + promptText.length > 0 ? `${stylePromptText(promptText, "muted")} ` : ""; output.write(`\r${ANSI.clearLine}`); await rl.question(question); } catch (error) { @@ -1575,7 +1686,12 @@ async function runActionPanel( ? UI_COPY.returnFlow.failed : UI_COPY.returnFlow.done; previousLog(stylePromptText(title, "accent")); - previousLog(stylePromptText(stageText, failed ? "danger" : running ? "accent" : "success")); + previousLog( + stylePromptText( + stageText, + failed ? "danger" : running ? "accent" : "success", + ), + ); previousLog(""); const lines = captured.slice(-maxVisibleLines); @@ -1588,7 +1704,8 @@ async function runActionPanel( previousLog(""); } previousLog(""); - if (running) previousLog(stylePromptText(UI_COPY.returnFlow.working, "muted")); + if (running) + previousLog(stylePromptText(UI_COPY.returnFlow.working, "muted")); frame += 1; }; @@ -1637,7 +1754,9 @@ async function runActionPanel( pauseOnAnyKey: settings?.actionPauseOnKey ?? true, }); } - output.write(ANSI.altScreenOff + ANSI.show + ANSI.clearScreen + ANSI.moveTo(1, 1)); + output.write( + ANSI.altScreenOff + ANSI.show + ANSI.clearScreen + ANSI.moveTo(1, 1), + ); if (failed) { throw failed; } @@ -1649,7 +1768,8 @@ async function runOAuthFlow( ): Promise { const { pkce, state, url } = await createAuthorizationFlow({ forceNewLogin }); let code: string | null = null; - let oauthServer: Awaited> | null = null; + let oauthServer: Awaited> | null = + null; try { if (signInMode === "browser") { try { @@ -1659,15 +1779,15 @@ async function runOAuthFlow( "Local OAuth callback server unavailable; falling back to manual callback entry.", serverError instanceof Error ? { - message: serverError.message, - stack: serverError.stack, - code: - typeof serverError === "object" && - serverError !== null && - "code" in serverError - ? String(serverError.code) - : undefined, - } + message: serverError.message, + stack: serverError.stack, + code: + typeof serverError === "object" && + serverError !== null && + "code" in serverError + ? String(serverError.code) + : undefined, + } : { error: String(serverError) }, ); oauthServer = null; @@ -1700,7 +1820,8 @@ async function runOAuthFlow( ); } - const waitingForCallback = signInMode === "browser" && oauthServer?.ready === true; + const waitingForCallback = + signInMode === "browser" && oauthServer?.ready === true; if (waitingForCallback && oauthServer) { console.log(stylePromptText(UI_COPY.oauth.waitingCallback, "muted")); const callbackResult = await oauthServer.waitForCode(state); @@ -1718,7 +1839,9 @@ async function runOAuthFlow( "warning", ), ); - code = await promptManualCallback(state, { allowNonTty: signInMode === "manual" }); + code = await promptManualCallback(state, { + allowNonTty: signInMode === "manual", + }); } } finally { oauthServer?.close(); @@ -1754,19 +1877,24 @@ async function persistAccountPool( tokenAccountId, ); const accountIdSource = accountId - ? (result.accountIdSource ?? (result.accountIdOverride ? "manual" : "token")) + ? (result.accountIdSource ?? + (result.accountIdOverride ? "manual" : "token")) : undefined; const accountLabel = result.accountLabel; const accountEmail = sanitizeEmail( extractAccountEmail(result.access, result.idToken), ); - const existingIndex = findMatchingAccountIndex(accounts, { - accountId, - email: accountEmail, - refreshToken: result.refresh, - }, { - allowUniqueAccountIdFallbackWithoutEmail: true, - }); + const existingIndex = findMatchingAccountIndex( + accounts, + { + accountId, + email: accountEmail, + refreshToken: result.refresh, + }, + { + allowUniqueAccountIdFallbackWithoutEmail: true, + }, + ); if (existingIndex === undefined) { const newIndex = accounts.length; @@ -1810,17 +1938,16 @@ async function persistAccountPool( selectedAccountIndex = existingIndex; } - const fallbackActiveIndex = accounts.length === 0 - ? 0 - : Math.max( - 0, - Math.min(stored?.activeIndex ?? 0, accounts.length - 1), - ); - const nextActiveIndex = accounts.length === 0 - ? 0 - : selectedAccountIndex === null - ? fallbackActiveIndex - : Math.max(0, Math.min(selectedAccountIndex, accounts.length - 1)); + const fallbackActiveIndex = + accounts.length === 0 + ? 0 + : Math.max(0, Math.min(stored?.activeIndex ?? 0, accounts.length - 1)); + const nextActiveIndex = + accounts.length === 0 + ? 0 + : selectedAccountIndex === null + ? fallbackActiveIndex + : Math.max(0, Math.min(selectedAccountIndex, accounts.length - 1)); const activeIndexByFamily: Partial> = {}; for (const family of MODEL_FAMILIES) { activeIndexByFamily[family] = nextActiveIndex; @@ -1835,14 +1962,18 @@ async function persistAccountPool( }); } -async function syncSelectionToCodex(tokens: TokenSuccessWithAccount): Promise { +async function syncSelectionToCodex( + tokens: TokenSuccessWithAccount, +): Promise { const tokenAccountId = extractAccountId(tokens.access); const accountId = resolveRequestAccountId( tokens.accountIdOverride, tokens.accountIdSource, tokenAccountId, ); - const email = sanitizeEmail(extractAccountEmail(tokens.access, tokens.idToken)); + const email = sanitizeEmail( + extractAccountEmail(tokens.access, tokens.idToken), + ); await setCodexCliActiveSelection({ accountId, email, @@ -1880,9 +2011,10 @@ async function showAccountStatus(): Promise { const cooldown = formatCooldown(account, now); if (cooldown) markers.push(`cooldown:${cooldown}`); const markerLabel = markers.length > 0 ? ` [${markers.join(", ")}]` : ""; - const lastUsed = typeof account.lastUsed === "number" && account.lastUsed > 0 - ? `used ${formatWaitTime(now - account.lastUsed)} ago` - : "never used"; + const lastUsed = + typeof account.lastUsed === "number" && account.lastUsed > 0 + ? `used ${formatWaitTime(now - account.lastUsed)} ago` + : "never used"; console.log(`${i + 1}. ${label}${markerLabel} ${lastUsed}`); } } @@ -1920,12 +2052,14 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { const activeIndex = resolveActiveIndex(storage, "codex"); let activeAccountRefreshed = false; const now = Date.now(); - console.log(stylePromptText( - forceRefresh - ? `Checking ${storage.accounts.length} account(s) with full refresh test...` - : `Checking ${storage.accounts.length} account(s) with quick check${liveProbe ? " + live check" : ""}...`, - "accent", - )); + console.log( + stylePromptText( + forceRefresh + ? `Checking ${storage.accounts.length} account(s) with full refresh test...` + : `Checking ${storage.accounts.length} account(s) with quick check${liveProbe ? " + live check" : ""}...`, + "accent", + ), + ); for (let i = 0; i < storage.accounts.length; i += 1) { const account = storage.accounts[i]; if (!account) continue; @@ -1948,7 +2082,8 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { : undefined; if (!probeAccountId || !currentAccessToken) { warnings += 1; - healthDetail = "signed in and working (live check skipped: missing account ID)"; + healthDetail = + "signed in and working (live check skipped: missing account ID)"; } else { try { const snapshot = await fetchCodexQuotaSnapshot({ @@ -1991,7 +2126,9 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { const result = await queuedRefresh(account.refreshToken); if (result.type === "success") { const tokenAccountId = extractAccountId(result.access); - const nextEmail = sanitizeEmail(extractAccountEmail(result.access, result.idToken)); + const nextEmail = sanitizeEmail( + extractAccountEmail(result.access, result.idToken), + ); const previousEmail = account.email; let accountIdentityChanged = false; if (account.refreshToken !== result.refresh) { @@ -2020,7 +2157,9 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { changed = true; } if (accountIdentityChanged && liveProbe && workingQuotaCache) { - quotaEmailFallbackState = buildQuotaEmailFallbackState(storage.accounts); + quotaEmailFallbackState = buildQuotaEmailFallbackState( + storage.accounts, + ); quotaCacheChanged = pruneUnsafeQuotaEmailCacheEntry( workingQuotaCache, @@ -2039,7 +2178,8 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { const probeAccountId = account.accountId ?? tokenAccountId; if (!probeAccountId) { warnings += 1; - healthyMessage = "working now (live check skipped: missing account ID)"; + healthyMessage = + "working now (live check skipped: missing account ID)"; } else { try { const snapshot = await fetchCodexQuotaSnapshot({ @@ -2094,7 +2234,12 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { } if (!display.showPerAccountRows) { - console.log(stylePromptText("Per-account lines are hidden in dashboard settings.", "muted")); + console.log( + stylePromptText( + "Per-account lines are hidden in dashboard settings.", + "muted", + ), + ); } if (workingQuotaCache && quotaCacheChanged) { await saveQuotaCache(workingQuotaCache); @@ -2104,7 +2249,11 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { await saveAccounts(storage); } - if (activeAccountRefreshed && activeIndex >= 0 && activeIndex < storage.accounts.length) { + if ( + activeAccountRefreshed && + activeIndex >= 0 && + activeIndex < storage.accounts.length + ) { const activeAccount = storage.accounts[activeIndex]; if (activeAccount) { await setCodexCliActiveSelection({ @@ -2118,11 +2267,19 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { } console.log(""); - console.log(formatResultSummary([ - { text: `${ok} working`, tone: "success" }, - { text: `${failed} need re-login`, tone: failed > 0 ? "danger" : "muted" }, - { text: `${warnings} warning${warnings === 1 ? "" : "s"}`, tone: warnings > 0 ? "warning" : "muted" }, - ])); + console.log( + formatResultSummary([ + { text: `${ok} working`, tone: "success" }, + { + text: `${failed} need re-login`, + tone: failed > 0 ? "danger" : "muted", + }, + { + text: `${warnings} warning${warnings === 1 ? "" : "s"}`, + tone: warnings > 0 ? "warning" : "muted", + }, + ]), + ); } interface ForecastCliOptions { @@ -2158,7 +2315,9 @@ interface VerifyFlaggedCliOptions { restore: boolean; } -type ParsedArgsResult = { ok: true; options: T } | { ok: false; message: string }; +type ParsedArgsResult = + | { ok: true; options: T } + | { ok: false; message: string }; function printForecastUsage(): void { console.log( @@ -2230,7 +2389,9 @@ function printVerifyFlaggedUsage(): void { ); } -function parseForecastArgs(args: string[]): ParsedArgsResult { +function parseForecastArgs( + args: string[], +): ParsedArgsResult { const options: ForecastCliOptions = { live: false, json: false, @@ -2362,7 +2523,9 @@ function parseFixArgs(args: string[]): ParsedArgsResult { return { ok: true, options }; } -function parseVerifyFlaggedArgs(args: string[]): ParsedArgsResult { +function parseVerifyFlaggedArgs( + args: string[], +): ParsedArgsResult { const options: VerifyFlaggedCliOptions = { dryRun: false, json: false, @@ -2511,7 +2674,10 @@ function parseReportArgs(args: string[]): ParsedArgsResult { function serializeForecastResults( results: ForecastAccountResult[], - liveQuotaByIndex: Map>>, + liveQuotaByIndex: Map< + number, + Awaited> + >, refreshFailures: Map, ): Array<{ index: number; @@ -2544,12 +2710,12 @@ function serializeForecastResults( reasons: result.reasons, liveQuota: liveQuota ? { - status: liveQuota.status, - planType: liveQuota.planType, - activeLimit: liveQuota.activeLimit, - model: liveQuota.model, - summary: formatQuotaSnapshotLine(liveQuota), - } + status: liveQuota.status, + planType: liveQuota.planType, + activeLimit: liveQuota.activeLimit, + model: liveQuota.model, + summary: formatQuotaSnapshotLine(liveQuota), + } : undefined, refreshFailure: refreshFailures.get(result.index), }; @@ -2588,7 +2754,10 @@ async function runForecast(args: string[]): Promise { const now = Date.now(); const activeIndex = resolveActiveIndex(storage, "codex"); const refreshFailures = new Map(); - const liveQuotaByIndex = new Map>>(); + const liveQuotaByIndex = new Map< + number, + Awaited> + >(); const probeErrors: string[] = []; for (let i = 0; i < storage.accounts.length; i += 1) { @@ -2597,22 +2766,29 @@ async function runForecast(args: string[]): Promise { if (account.enabled === false) continue; let probeAccessToken = account.accessToken; - let probeAccountId = account.accountId ?? extractAccountId(account.accessToken); + let probeAccountId = + account.accountId ?? extractAccountId(account.accessToken); if (!hasUsableAccessToken(account, now)) { const refreshResult = await queuedRefresh(account.refreshToken); if (refreshResult.type !== "success") { refreshFailures.set(i, { ...refreshResult, - message: normalizeFailureDetail(refreshResult.message, refreshResult.reason), + message: normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ), }); continue; } probeAccessToken = refreshResult.access; - probeAccountId = account.accountId ?? extractAccountId(refreshResult.access); + probeAccountId = + account.accountId ?? extractAccountId(refreshResult.access); } if (!probeAccessToken || !probeAccountId) { - probeErrors.push(`${formatAccountLabel(account, i)}: missing accountId for live probe`); + probeErrors.push( + `${formatAccountLabel(account, i)}: missing accountId for live probe`, + ); continue; } @@ -2670,7 +2846,11 @@ async function runForecast(args: string[]): Promise { summary, recommendation, probeErrors, - accounts: serializeForecastResults(forecastResults, liveQuotaByIndex, refreshFailures), + accounts: serializeForecastResults( + forecastResults, + liveQuotaByIndex, + refreshFailures, + ), }, null, 2, @@ -2689,8 +2869,14 @@ async function runForecast(args: string[]): Promise { formatResultSummary([ { text: `${summary.ready} ready now`, tone: "success" }, { text: `${summary.delayed} waiting`, tone: "warning" }, - { text: `${summary.unavailable} unavailable`, tone: summary.unavailable > 0 ? "danger" : "muted" }, - { text: `${summary.highRisk} high risk`, tone: summary.highRisk > 0 ? "danger" : "muted" }, + { + text: `${summary.unavailable} unavailable`, + tone: summary.unavailable > 0 ? "danger" : "muted", + }, + { + text: `${summary.highRisk} high risk`, + tone: summary.highRisk > 0 ? "danger" : "muted", + }, ]), ); console.log(""); @@ -2700,25 +2886,48 @@ async function runForecast(args: string[]): Promise { continue; } const currentTag = result.isCurrent ? " [current]" : ""; - const waitLabel = result.waitMs > 0 ? stylePromptText(`wait ${formatWaitTime(result.waitMs)}`, "muted") : ""; + const waitLabel = + result.waitMs > 0 + ? stylePromptText(`wait ${formatWaitTime(result.waitMs)}`, "muted") + : ""; const indexLabel = stylePromptText(`${result.index + 1}.`, "accent"); - const accountLabel = stylePromptText(`${result.label}${currentTag}`, "accent"); - const riskLabel = stylePromptText(`${result.riskLevel} risk (${result.riskScore})`, riskTone(result.riskLevel)); - const availabilityLabel = stylePromptText(result.availability, availabilityTone(result.availability)); + const accountLabel = stylePromptText( + `${result.label}${currentTag}`, + "accent", + ); + const riskLabel = stylePromptText( + `${result.riskLevel} risk (${result.riskScore})`, + riskTone(result.riskLevel), + ); + const availabilityLabel = stylePromptText( + result.availability, + availabilityTone(result.availability), + ); const rowParts = [availabilityLabel, riskLabel]; if (waitLabel) rowParts.push(waitLabel); - console.log(`${indexLabel} ${accountLabel} ${stylePromptText("|", "muted")} ${joinStyledSegments(rowParts)}`); + console.log( + `${indexLabel} ${accountLabel} ${stylePromptText("|", "muted")} ${joinStyledSegments(rowParts)}`, + ); if (display.showForecastReasons && result.reasons.length > 0) { - console.log(` ${stylePromptText(result.reasons.slice(0, 3).join("; "), "muted")}`); + console.log( + ` ${stylePromptText(result.reasons.slice(0, 3).join("; "), "muted")}`, + ); } const liveQuota = liveQuotaByIndex.get(result.index); if (display.showQuotaDetails && liveQuota) { - console.log(` ${stylePromptText("quota:", "accent")} ${styleQuotaSummary(formatCompactQuotaSnapshot(liveQuota))}`); + console.log( + ` ${stylePromptText("quota:", "accent")} ${styleQuotaSummary(formatCompactQuotaSnapshot(liveQuota))}`, + ); } } if (!display.showPerAccountRows) { - console.log(stylePromptText("Per-account lines are hidden in dashboard settings.", "muted")); + console.log( + stylePromptText( + "Per-account lines are hidden in dashboard settings.", + "muted", + ), + ); } if (display.showRecommendations) { @@ -2730,21 +2939,31 @@ async function runForecast(args: string[]): Promise { console.log( `${stylePromptText("Best next account:", "accent")} ${stylePromptText(`${index + 1} (${account.label})`, "success")}`, ); - console.log(`${stylePromptText("Why:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`); + console.log( + `${stylePromptText("Why:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`, + ); if (index !== activeIndex) { - console.log(`${stylePromptText("Switch now with:", "accent")} codex auth switch ${index + 1}`); + console.log( + `${stylePromptText("Switch now with:", "accent")} codex auth switch ${index + 1}`, + ); } } } else { - console.log(`${stylePromptText("Note:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`); + console.log( + `${stylePromptText("Note:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`, + ); } } if (display.showLiveProbeNotes && probeErrors.length > 0) { console.log(""); - console.log(stylePromptText(`Live check notes (${probeErrors.length}):`, "warning")); + console.log( + stylePromptText(`Live check notes (${probeErrors.length}):`, "warning"), + ); for (const error of probeErrors) { - console.log(` ${stylePromptText("-", "warning")} ${stylePromptText(error, "muted")}`); + console.log( + ` ${stylePromptText("-", "warning")} ${stylePromptText(error, "muted")}`, + ); } } if (workingQuotaCache && quotaCacheChanged) { @@ -2775,7 +2994,10 @@ async function runReport(args: string[]): Promise { const accountCount = storage?.accounts.length ?? 0; const activeIndex = storage ? resolveActiveIndex(storage, "codex") : 0; const refreshFailures = new Map(); - const liveQuotaByIndex = new Map>>(); + const liveQuotaByIndex = new Map< + number, + Awaited> + >(); const probeErrors: string[] = []; if (storage && options.live) { @@ -2787,14 +3009,20 @@ async function runReport(args: string[]): Promise { if (refreshResult.type !== "success") { refreshFailures.set(i, { ...refreshResult, - message: normalizeFailureDetail(refreshResult.message, refreshResult.reason), + message: normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ), }); continue; } - const accountId = account.accountId ?? extractAccountId(refreshResult.access); + const accountId = + account.accountId ?? extractAccountId(refreshResult.access); if (!accountId) { - probeErrors.push(`${formatAccountLabel(account, i)}: missing accountId for live probe`); + probeErrors.push( + `${formatAccountLabel(account, i)}: missing accountId for live probe`, + ); continue; } @@ -2836,11 +3064,14 @@ async function runReport(args: string[]): Promise { const coolingCount = storage ? storage.accounts.filter( (account) => - typeof account.coolingDownUntil === "number" && account.coolingDownUntil > now, + typeof account.coolingDownUntil === "number" && + account.coolingDownUntil > now, ).length : 0; const rateLimitedCount = storage - ? storage.accounts.filter((account) => !!formatRateLimitEntry(account, now, "codex")).length + ? storage.accounts.filter( + (account) => !!formatRateLimitEntry(account, now, "codex"), + ).length : 0; const report = { @@ -2861,14 +3092,22 @@ async function runReport(args: string[]): Promise { summary: forecastSummary, recommendation, probeErrors, - accounts: serializeForecastResults(forecastResults, liveQuotaByIndex, refreshFailures), + accounts: serializeForecastResults( + forecastResults, + liveQuotaByIndex, + refreshFailures, + ), }, }; if (options.outPath) { const outputPath = resolve(process.cwd(), options.outPath); await fs.mkdir(dirname(outputPath), { recursive: true }); - await fs.writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, "utf-8"); + await fs.writeFile( + outputPath, + `${JSON.stringify(report, null, 2)}\n`, + "utf-8", + ); } if (options.json) { @@ -2916,9 +3155,7 @@ interface FixAccountReport { message: string; } -function summarizeFixReports( - reports: FixAccountReport[], -): { +function summarizeFixReports(reports: FixAccountReport[]): { healthy: number; disabled: number; warnings: number; @@ -2967,24 +3204,32 @@ function findExistingAccountIndexForFlagged( const flaggedEmail = sanitizeEmail(flagged.email); const candidateAccountId = nextAccountId ?? flagged.accountId; const candidateEmail = sanitizeEmail(nextEmail) ?? flaggedEmail; - const nextMatchIndex = findMatchingAccountIndex(storage.accounts, { - accountId: candidateAccountId, - email: candidateEmail, - refreshToken: nextRefreshToken, - }, { - allowUniqueAccountIdFallbackWithoutEmail: true, - }); + const nextMatchIndex = findMatchingAccountIndex( + storage.accounts, + { + accountId: candidateAccountId, + email: candidateEmail, + refreshToken: nextRefreshToken, + }, + { + allowUniqueAccountIdFallbackWithoutEmail: true, + }, + ); if (nextMatchIndex !== undefined) { return nextMatchIndex; } - const flaggedMatchIndex = findMatchingAccountIndex(storage.accounts, { - accountId: candidateAccountId, - email: candidateEmail, - refreshToken: flagged.refreshToken, - }, { - allowUniqueAccountIdFallbackWithoutEmail: true, - }); + const flaggedMatchIndex = findMatchingAccountIndex( + storage.accounts, + { + accountId: candidateAccountId, + email: candidateEmail, + refreshToken: flagged.refreshToken, + }, + { + allowUniqueAccountIdFallbackWithoutEmail: true, + }, + ); return flaggedMatchIndex ?? -1; } @@ -2994,10 +3239,17 @@ function upsertRecoveredFlaggedAccount( refreshResult: TokenSuccess, now: number, ): { restored: boolean; changed: boolean; message: string } { - const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)) ?? flagged.email; + const nextEmail = + sanitizeEmail( + extractAccountEmail(refreshResult.access, refreshResult.idToken), + ) ?? flagged.email; const tokenAccountId = extractAccountId(refreshResult.access); const { accountId: nextAccountId, accountIdSource: nextAccountIdSource } = - resolveStoredAccountIdentity(flagged.accountId, flagged.accountIdSource, tokenAccountId); + resolveStoredAccountIdentity( + flagged.accountId, + flagged.accountIdSource, + tokenAccountId, + ); const existingIndex = findExistingAccountIndexForFlagged( storage, flagged, @@ -3009,7 +3261,11 @@ function upsertRecoveredFlaggedAccount( if (existingIndex >= 0) { const existing = storage.accounts[existingIndex]; if (!existing) { - return { restored: false, changed: false, message: "existing account entry is missing" }; + return { + restored: false, + changed: false, + message: "existing account entry is missing", + }; } let changed = false; if (existing.refreshToken !== refreshResult.refresh) { @@ -3030,10 +3286,8 @@ function upsertRecoveredFlaggedAccount( } if ( nextAccountId !== undefined && - ( - (nextAccountId !== existing.accountId) - || (nextAccountIdSource !== existing.accountIdSource) - ) + (nextAccountId !== existing.accountId || + nextAccountIdSource !== existing.accountIdSource) ) { existing.accountId = nextAccountId; existing.accountIdSource = nextAccountIdSource; @@ -3043,7 +3297,10 @@ function upsertRecoveredFlaggedAccount( existing.enabled = true; changed = true; } - if (existing.accountLabel !== flagged.accountLabel && flagged.accountLabel) { + if ( + existing.accountLabel !== flagged.accountLabel && + flagged.accountLabel + ) { existing.accountLabel = flagged.accountLabel; changed = true; } @@ -3147,9 +3404,7 @@ async function runVerifyFlagged(args: string[]): Promise { }); } - const applyRefreshChecks = ( - storage: AccountStorageV3, - ): void => { + const applyRefreshChecks = (storage: AccountStorageV3): void => { for (const check of refreshChecks) { const { index: i, flagged, label, result } = check; if (result.type === "success") { @@ -3167,7 +3422,10 @@ async function runVerifyFlagged(args: string[]): Promise { expiresAt: result.expires, accountId: nextIdentity.accountId, accountIdSource: nextIdentity.accountIdSource, - email: sanitizeEmail(extractAccountEmail(result.access, result.idToken)) ?? flagged.email, + email: + sanitizeEmail( + extractAccountEmail(result.access, result.idToken), + ) ?? flagged.email, lastUsed: now, lastError: undefined, }; @@ -3179,12 +3437,18 @@ async function runVerifyFlagged(args: string[]): Promise { index: i, label, outcome: "healthy-flagged", - message: "session is healthy (left in flagged list due to --no-restore)", + message: + "session is healthy (left in flagged list due to --no-restore)", }); continue; } - const upsertResult = upsertRecoveredFlaggedAccount(storage, flagged, result, now); + const upsertResult = upsertRecoveredFlaggedAccount( + storage, + flagged, + result, + now, + ); if (upsertResult.restored) { storageChanged = storageChanged || upsertResult.changed; flaggedChanged = true; @@ -3210,7 +3474,9 @@ async function runVerifyFlagged(args: string[]): Promise { expiresAt: result.expires, accountId: nextIdentity.accountId, accountIdSource: nextIdentity.accountIdSource, - email: sanitizeEmail(extractAccountEmail(result.access, result.idToken)) ?? flagged.email, + email: + sanitizeEmail(extractAccountEmail(result.access, result.idToken)) ?? + flagged.email, lastUsed: now, lastError: upsertResult.message, }; @@ -3247,9 +3513,7 @@ async function runVerifyFlagged(args: string[]): Promise { if (options.restore) { if (options.dryRun) { - applyRefreshChecks( - (await loadAccounts()) ?? createEmptyAccountStorage(), - ); + applyRefreshChecks((await loadAccounts()) ?? createEmptyAccountStorage()); } else { await withAccountAndFlaggedStorageTransaction( async (loadedStorage, persist) => { @@ -3273,12 +3537,22 @@ async function runVerifyFlagged(args: string[]): Promise { } const remainingFlagged = nextFlaggedAccounts.length; - const restored = reports.filter((report) => report.outcome === "restored").length; - const healthyFlagged = reports.filter((report) => report.outcome === "healthy-flagged").length; - const stillFlagged = reports.filter((report) => report.outcome === "still-flagged").length; + const restored = reports.filter( + (report) => report.outcome === "restored", + ).length; + const healthyFlagged = reports.filter( + (report) => report.outcome === "healthy-flagged", + ).length; + const stillFlagged = reports.filter( + (report) => report.outcome === "still-flagged", + ).length; const changed = storageChanged || flaggedChanged; - if (!options.dryRun && flaggedChanged && (!options.restore || !storageChanged)) { + if ( + !options.dryRun && + flaggedChanged && + (!options.restore || !storageChanged) + ) { await saveFlaggedAccounts({ version: 1, accounts: nextFlaggedAccounts, @@ -3314,32 +3588,47 @@ async function runVerifyFlagged(args: string[]): Promise { ), ); for (const report of reports) { - const tone = report.outcome === "restored" - ? "success" - : report.outcome === "healthy-flagged" - ? "warning" - : report.outcome === "restore-skipped" + const tone = + report.outcome === "restored" + ? "success" + : report.outcome === "healthy-flagged" ? "warning" - : "danger"; - const marker = report.outcome === "restored" - ? "✓" - : report.outcome === "healthy-flagged" - ? "!" - : report.outcome === "restore-skipped" + : report.outcome === "restore-skipped" + ? "warning" + : "danger"; + const marker = + report.outcome === "restored" + ? "✓" + : report.outcome === "healthy-flagged" ? "!" - : "✗"; + : report.outcome === "restore-skipped" + ? "!" + : "✗"; console.log( `${stylePromptText(marker, tone)} ${stylePromptText(`${report.index + 1}. ${report.label}`, "accent")} ${stylePromptText("|", "muted")} ${styleAccountDetailText(report.message, tone)}`, ); } console.log(""); - console.log(formatResultSummary([ - { text: `${restored} restored`, tone: restored > 0 ? "success" : "muted" }, - { text: `${healthyFlagged} healthy (kept flagged)`, tone: healthyFlagged > 0 ? "warning" : "muted" }, - { text: `${stillFlagged} still flagged`, tone: stillFlagged > 0 ? "danger" : "muted" }, - ])); + console.log( + formatResultSummary([ + { + text: `${restored} restored`, + tone: restored > 0 ? "success" : "muted", + }, + { + text: `${healthyFlagged} healthy (kept flagged)`, + tone: healthyFlagged > 0 ? "warning" : "muted", + }, + { + text: `${stillFlagged} still flagged`, + tone: stillFlagged > 0 ? "danger" : "muted", + }, + ]), + ); if (options.dryRun) { - console.log(stylePromptText("Preview only: no changes were saved.", "warning")); + console.log( + stylePromptText("Preview only: no changes were saved.", "warning"), + ); } else if (!changed) { console.log(stylePromptText("No storage changes were needed.", "muted")); } @@ -3459,7 +3748,9 @@ async function runFix(args: string[]): Promise { const refreshResult = await queuedRefresh(account.refreshToken); if (refreshResult.type === "success") { - const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)); + const nextEmail = sanitizeEmail( + extractAccountEmail(refreshResult.access, refreshResult.idToken), + ); const nextAccountId = extractAccountId(refreshResult.access); const previousEmail = account.email; let accountChanged = false; @@ -3489,7 +3780,9 @@ async function runFix(args: string[]): Promise { if (accountChanged) changed = true; if (accountIdentityChanged && options.live && workingQuotaCache) { - quotaEmailFallbackState = buildQuotaEmailFallbackState(storage.accounts); + quotaEmailFallbackState = buildQuotaEmailFallbackState( + storage.accounts, + ); quotaCacheChanged = pruneUnsafeQuotaEmailCacheEntry( workingQuotaCache, @@ -3550,7 +3843,10 @@ async function runFix(args: string[]): Promise { continue; } - const detail = normalizeFailureDetail(refreshResult.message, refreshResult.reason); + const detail = normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ); refreshFailures.set(i, { ...refreshResult, message: detail, @@ -3576,13 +3872,17 @@ async function runFix(args: string[]): Promise { } if (hardDisabledIndexes.length > 0) { - const enabledCount = storage.accounts.filter((account) => account.enabled !== false).length; + const enabledCount = storage.accounts.filter( + (account) => account.enabled !== false, + ).length; if (enabledCount === 0) { - const fallbackIndex = - hardDisabledIndexes.includes(activeIndex) ? activeIndex : hardDisabledIndexes[0]; - const fallback = typeof fallbackIndex === "number" - ? storage.accounts[fallbackIndex] - : undefined; + const fallbackIndex = hardDisabledIndexes.includes(activeIndex) + ? activeIndex + : hardDisabledIndexes[0]; + const fallback = + typeof fallbackIndex === "number" + ? storage.accounts[fallbackIndex] + : undefined; if (fallback && fallback.enabled === false) { fallback.enabled = true; changed = true; @@ -3631,7 +3931,7 @@ async function runFix(args: string[]): Promise { recommendation, recommendedSwitchCommand: recommendation.recommendedIndex !== null && - recommendation.recommendedIndex !== activeIndex + recommendation.recommendedIndex !== activeIndex ? `codex auth switch ${recommendation.recommendedIndex + 1}` : null, reports, @@ -3643,16 +3943,26 @@ async function runFix(args: string[]): Promise { return 0; } - console.log(stylePromptText(`Auto-fix scan (${options.dryRun ? "preview" : "apply"})`, "accent")); - console.log(formatResultSummary([ - { text: `${reportSummary.healthy} working`, tone: "success" }, - { text: `${reportSummary.disabled} disabled`, tone: reportSummary.disabled > 0 ? "danger" : "muted" }, - { - text: `${reportSummary.warnings} warning${reportSummary.warnings === 1 ? "" : "s"}`, - tone: reportSummary.warnings > 0 ? "warning" : "muted", - }, - { text: `${reportSummary.skipped} already disabled`, tone: "muted" }, - ])); + console.log( + stylePromptText( + `Auto-fix scan (${options.dryRun ? "preview" : "apply"})`, + "accent", + ), + ); + console.log( + formatResultSummary([ + { text: `${reportSummary.healthy} working`, tone: "success" }, + { + text: `${reportSummary.disabled} disabled`, + tone: reportSummary.disabled > 0 ? "danger" : "muted", + }, + { + text: `${reportSummary.warnings} warning${reportSummary.warnings === 1 ? "" : "s"}`, + tone: reportSummary.warnings > 0 ? "warning" : "muted", + }, + { text: `${reportSummary.skipped} already disabled`, tone: "muted" }, + ]), + ); if (display.showPerAccountRows) { console.log(""); for (const report of reports) { @@ -3664,33 +3974,47 @@ async function runFix(args: string[]): Promise { : report.outcome === "warning-soft-failure" ? "!" : "-"; - const tone = report.outcome === "healthy" - ? "success" - : report.outcome === "disabled-hard-failure" - ? "danger" - : report.outcome === "warning-soft-failure" - ? "warning" - : "muted"; + const tone = + report.outcome === "healthy" + ? "success" + : report.outcome === "disabled-hard-failure" + ? "danger" + : report.outcome === "warning-soft-failure" + ? "warning" + : "muted"; console.log( `${stylePromptText(prefix, tone)} ${stylePromptText(`${report.index + 1}. ${report.label}`, "accent")} ${stylePromptText("|", "muted")} ${styleAccountDetailText(report.message, tone === "success" ? "muted" : tone)}`, ); } } else { console.log(""); - console.log(stylePromptText("Per-account lines are hidden in dashboard settings.", "muted")); + console.log( + stylePromptText( + "Per-account lines are hidden in dashboard settings.", + "muted", + ), + ); } if (display.showRecommendations) { console.log(""); if (recommendation.recommendedIndex !== null) { const target = recommendation.recommendedIndex + 1; - console.log(`${stylePromptText("Best next account:", "accent")} ${stylePromptText(String(target), "success")}`); - console.log(`${stylePromptText("Why:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`); + console.log( + `${stylePromptText("Best next account:", "accent")} ${stylePromptText(String(target), "success")}`, + ); + console.log( + `${stylePromptText("Why:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`, + ); if (recommendation.recommendedIndex !== activeIndex) { - console.log(`${stylePromptText("Switch now with:", "accent")} codex auth switch ${target}`); + console.log( + `${stylePromptText("Switch now with:", "accent")} codex auth switch ${target}`, + ); } } else { - console.log(`${stylePromptText("Note:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`); + console.log( + `${stylePromptText("Note:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`, + ); } } if (workingQuotaCache && quotaCacheChanged) { @@ -3698,7 +4022,9 @@ async function runFix(args: string[]): Promise { } if (changed && options.dryRun) { - console.log(`\n${stylePromptText("Preview only: no changes were saved.", "warning")}`); + console.log( + `\n${stylePromptText("Preview only: no changes were saved.", "warning")}`, + ); } else if (changed) { console.log(`\n${stylePromptText("Saved updates.", "success")}`); } else { @@ -3736,7 +4062,8 @@ function hasPlaceholderEmail(value: string | undefined): boolean { function normalizeDoctorIndexes(storage: AccountStorageV3): boolean { const total = storage.accounts.length; - const nextActive = total === 0 ? 0 : Math.max(0, Math.min(storage.activeIndex, total - 1)); + const nextActive = + total === 0 ? 0 : Math.max(0, Math.min(storage.activeIndex, total - 1)); let changed = false; if (storage.activeIndex !== nextActive) { storage.activeIndex = nextActive; @@ -3746,8 +4073,10 @@ function normalizeDoctorIndexes(storage: AccountStorageV3): boolean { for (const family of MODEL_FAMILIES) { const raw = storage.activeIndexByFamily[family]; const fallback = storage.activeIndex; - const candidate = typeof raw === "number" && Number.isFinite(raw) ? raw : fallback; - const clamped = total === 0 ? 0 : Math.max(0, Math.min(candidate, total - 1)); + const candidate = + typeof raw === "number" && Number.isFinite(raw) ? raw : fallback; + const clamped = + total === 0 ? 0 : Math.max(0, Math.min(candidate, total - 1)); if (storage.activeIndexByFamily[family] !== clamped) { storage.activeIndexByFamily[family] = clamped; changed = true; @@ -3756,15 +4085,16 @@ function normalizeDoctorIndexes(storage: AccountStorageV3): boolean { return changed; } -function getDoctorRefreshTokenKey( - refreshToken: unknown, -): string | undefined { +function getDoctorRefreshTokenKey(refreshToken: unknown): string | undefined { if (typeof refreshToken !== "string") return undefined; const trimmed = refreshToken.trim(); return trimmed || undefined; } -function applyDoctorFixes(storage: AccountStorageV3): { changed: boolean; actions: DoctorFixAction[] } { +function applyDoctorFixes(storage: AccountStorageV3): { + changed: boolean; + actions: DoctorFixAction[]; +} { let changed = false; const actions: DoctorFixAction[] = []; @@ -3822,7 +4152,9 @@ function applyDoctorFixes(storage: AccountStorageV3): { changed: boolean; action } } - const enabledCount = storage.accounts.filter((account) => account.enabled !== false).length; + const enabledCount = storage.accounts.filter( + (account) => account.enabled !== false, + ).length; if (storage.accounts.length > 0 && enabledCount === 0) { const index = resolveActiveIndex(storage, "codex"); const candidate = storage.accounts[index] ?? storage.accounts[0]; @@ -3879,7 +4211,8 @@ async function runDoctor(args: string[]): Promise { addCheck({ key: "storage-readable", severity: stat.size > 0 ? "ok" : "warn", - message: stat.size > 0 ? "Storage file is readable" : "Storage file is empty", + message: + stat.size > 0 ? "Storage file is readable" : "Storage file is empty", details: `${stat.size} bytes`, }); } catch (error) { @@ -3912,20 +4245,27 @@ async function runDoctor(args: string[]): Promise { const parsed = JSON.parse(raw) as unknown; if (parsed && typeof parsed === "object") { const payload = parsed as Record; - const tokens = payload.tokens && typeof payload.tokens === "object" - ? (payload.tokens as Record) - : null; - const accessToken = tokens && typeof tokens.access_token === "string" - ? tokens.access_token - : undefined; - const idToken = tokens && typeof tokens.id_token === "string" - ? tokens.id_token - : undefined; - const accountIdFromFile = tokens && typeof tokens.account_id === "string" - ? tokens.account_id - : undefined; - const emailFromFile = typeof payload.email === "string" ? payload.email : undefined; - codexAuthEmail = sanitizeEmail(emailFromFile ?? extractAccountEmail(accessToken, idToken)); + const tokens = + payload.tokens && typeof payload.tokens === "object" + ? (payload.tokens as Record) + : null; + const accessToken = + tokens && typeof tokens.access_token === "string" + ? tokens.access_token + : undefined; + const idToken = + tokens && typeof tokens.id_token === "string" + ? tokens.id_token + : undefined; + const accountIdFromFile = + tokens && typeof tokens.account_id === "string" + ? tokens.account_id + : undefined; + const emailFromFile = + typeof payload.email === "string" ? payload.email : undefined; + codexAuthEmail = sanitizeEmail( + emailFromFile ?? extractAccountEmail(accessToken, idToken), + ); codexAuthAccountId = accountIdFromFile ?? extractAccountId(accessToken); } addCheck({ @@ -3960,7 +4300,9 @@ async function runDoctor(args: string[]): Promise { if (existsSync(codexConfigPath)) { try { const configRaw = await fs.readFile(codexConfigPath, "utf-8"); - const match = configRaw.match(/^\s*cli_auth_credentials_store\s*=\s*"([^"]+)"\s*$/m); + const match = configRaw.match( + /^\s*cli_auth_credentials_store\s*=\s*"([^"]+)"\s*$/m, + ); if (match?.[1]) { codexAuthStoreMode = match[1].trim(); } @@ -4029,7 +4371,8 @@ async function runDoctor(args: string[]): Promise { }); const activeIndex = resolveActiveIndex(storage, "codex"); - const activeExists = activeIndex >= 0 && activeIndex < storage.accounts.length; + const activeExists = + activeIndex >= 0 && activeIndex < storage.accounts.length; addCheck({ key: "active-index", severity: activeExists ? "ok" : "error", @@ -4038,7 +4381,9 @@ async function runDoctor(args: string[]): Promise { : "Active index is out of range", }); - const disabledCount = storage.accounts.filter((a) => a.enabled === false).length; + const disabledCount = storage.accounts.filter( + (a) => a.enabled === false, + ).length; addCheck({ key: "enabled-accounts", severity: disabledCount >= storage.accounts.length ? "error" : "ok", @@ -4117,7 +4462,10 @@ async function runDoctor(args: string[]): Promise { })), ); const recommendation = recommendForecastAccount(forecastResults); - if (recommendation.recommendedIndex !== null && recommendation.recommendedIndex !== activeIndex) { + if ( + recommendation.recommendedIndex !== null && + recommendation.recommendedIndex !== activeIndex + ) { addCheck({ key: "recommended-switch", severity: "warn", @@ -4136,8 +4484,10 @@ async function runDoctor(args: string[]): Promise { const activeAccount = storage.accounts[activeIndex]; const managerActiveEmail = sanitizeEmail(activeAccount?.email); const managerActiveAccountId = activeAccount?.accountId; - const codexActiveEmail = sanitizeEmail(codexCliState?.activeEmail) ?? codexAuthEmail; - const codexActiveAccountId = codexCliState?.activeAccountId ?? codexAuthAccountId; + const codexActiveEmail = + sanitizeEmail(codexCliState?.activeEmail) ?? codexAuthEmail; + const codexActiveAccountId = + codexCliState?.activeAccountId ?? codexAuthAccountId; const isEmailMismatch = !!managerActiveEmail && !!codexActiveEmail && @@ -4171,10 +4521,15 @@ async function runDoctor(args: string[]): Promise { message: `Prepared active-account token refresh for account ${activeIndex + 1} (dry-run)`, }); } else { - const refreshResult = await queuedRefresh(activeAccount.refreshToken); + const refreshResult = await queuedRefresh( + activeAccount.refreshToken, + ); if (refreshResult.type === "success") { const refreshedEmail = sanitizeEmail( - extractAccountEmail(refreshResult.access, refreshResult.idToken), + extractAccountEmail( + refreshResult.access, + refreshResult.idToken, + ), ); const refreshedAccountId = extractAccountId(refreshResult.access); activeAccount.accessToken = refreshResult.access; @@ -4196,7 +4551,10 @@ async function runDoctor(args: string[]): Promise { key: "doctor-refresh", severity: "warn", message: "Unable to refresh active account before Codex sync", - details: normalizeFailureDetail(refreshResult.message, refreshResult.reason), + details: normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ), }); } } @@ -4228,7 +4586,8 @@ async function runDoctor(args: string[]): Promise { addCheck({ key: "codex-active-sync", severity: "warn", - message: "Failed to sync manager active account into Codex auth state", + message: + "Failed to sync manager active account into Codex auth state", }); } } else { @@ -4273,10 +4632,13 @@ async function runDoctor(args: string[]): Promise { console.log("Doctor diagnostics"); console.log(`Storage: ${storagePath}`); - console.log(`Summary: ${summary.ok} ok, ${summary.warn} warnings, ${summary.error} errors`); + console.log( + `Summary: ${summary.ok} ok, ${summary.warn} warnings, ${summary.error} errors`, + ); console.log(""); for (const check of checks) { - const marker = check.severity === "ok" ? "✓" : check.severity === "warn" ? "!" : "✗"; + const marker = + check.severity === "ok" ? "✓" : check.severity === "warn" ? "!" : "✗"; console.log(`${marker} ${check.key}: ${check.message}`); if (check.details) { console.log(` ${check.details}`); @@ -4285,7 +4647,9 @@ async function runDoctor(args: string[]): Promise { if (options.fix) { console.log(""); if (fixActions.length > 0) { - console.log(`Auto-fix actions (${options.dryRun ? "dry-run" : "applied"}):`); + console.log( + `Auto-fix actions (${options.dryRun ? "dry-run" : "applied"}):`, + ); for (const action of fixActions) { console.log(` - ${action.message}`); } @@ -4355,7 +4719,9 @@ async function handleManageAction( const tokenResult = await runOAuthFlow(true, signInMode); if (tokenResult.type !== "success") { - console.error(`Refresh failed: ${tokenResult.message ?? tokenResult.reason ?? "unknown error"}`); + console.error( + `Refresh failed: ${tokenResult.message ?? tokenResult.reason ?? "unknown error"}`, + ); return; } @@ -4381,8 +4747,7 @@ async function runAuthLogin(args: string[]): Promise { setStoragePath(null); let pendingMenuQuotaRefresh: Promise | null = null; let menuQuotaRefreshStatus: string | undefined; - loginFlow: - while (true) { + loginFlow: while (true) { let existingStorage = await loadAccounts(); if (existingStorage && existingStorage.accounts.length > 0) { while (true) { @@ -4394,11 +4759,17 @@ async function runAuthLogin(args: string[]): Promise { const displaySettings = await loadDashboardDisplaySettings(); applyUiThemeFromDashboardSettings(displaySettings); const quotaCache = await loadQuotaCache(); - const shouldAutoFetchLimits = displaySettings.menuAutoFetchLimits ?? true; + const shouldAutoFetchLimits = + displaySettings.menuAutoFetchLimits ?? true; const showFetchStatus = displaySettings.menuShowFetchStatus ?? true; - const quotaTtlMs = displaySettings.menuQuotaTtlMs ?? DEFAULT_MENU_QUOTA_REFRESH_TTL_MS; + const quotaTtlMs = + displaySettings.menuQuotaTtlMs ?? DEFAULT_MENU_QUOTA_REFRESH_TTL_MS; if (shouldAutoFetchLimits && !pendingMenuQuotaRefresh) { - const staleCount = countMenuQuotaRefreshTargets(currentStorage, quotaCache, quotaTtlMs); + const staleCount = countMenuQuotaRefreshTargets( + currentStorage, + quotaCache, + quotaTtlMs, + ); if (staleCount > 0) { if (showFetchStatus) { menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [0/${staleCount}]`; @@ -4426,7 +4797,9 @@ async function runAuthLogin(args: string[]): Promise { toExistingAccountInfo(currentStorage, quotaCache, displaySettings), { flaggedCount: flaggedStorage.accounts.length, - statusMessage: showFetchStatus ? () => menuQuotaRefreshStatus : undefined, + statusMessage: showFetchStatus + ? () => menuQuotaRefreshStatus + : undefined, }, ); @@ -4435,27 +4808,47 @@ async function runAuthLogin(args: string[]): Promise { return 0; } if (menuResult.mode === "check") { - await runActionPanel("Quick Check", "Checking local session + live status", async () => { - await runHealthCheck({ forceRefresh: false, liveProbe: true }); - }, displaySettings); + await runActionPanel( + "Quick Check", + "Checking local session + live status", + async () => { + await runHealthCheck({ forceRefresh: false, liveProbe: true }); + }, + displaySettings, + ); continue; } if (menuResult.mode === "deep-check") { - await runActionPanel("Deep Check", "Refreshing and testing all accounts", async () => { - await runHealthCheck({ forceRefresh: true, liveProbe: true }); - }, displaySettings); + await runActionPanel( + "Deep Check", + "Refreshing and testing all accounts", + async () => { + await runHealthCheck({ forceRefresh: true, liveProbe: true }); + }, + displaySettings, + ); continue; } if (menuResult.mode === "forecast") { - await runActionPanel("Best Account", "Comparing accounts", async () => { - await runForecast(["--live"]); - }, displaySettings); + await runActionPanel( + "Best Account", + "Comparing accounts", + async () => { + await runForecast(["--live"]); + }, + displaySettings, + ); continue; } if (menuResult.mode === "fix") { - await runActionPanel("Auto-Fix", "Checking and fixing common issues", async () => { - await runFix(["--live"]); - }, displaySettings); + await runActionPanel( + "Auto-Fix", + "Checking and fixing common issues", + async () => { + await runFix(["--live"]); + }, + displaySettings, + ); continue; } if (menuResult.mode === "settings") { @@ -4463,27 +4856,45 @@ async function runAuthLogin(args: string[]): Promise { continue; } if (menuResult.mode === "verify-flagged") { - await runActionPanel("Problem Account Check", "Checking problem accounts", async () => { - await runVerifyFlagged([]); - }, displaySettings); + await runActionPanel( + "Problem Account Check", + "Checking problem accounts", + async () => { + await runVerifyFlagged([]); + }, + displaySettings, + ); continue; } if (menuResult.mode === "fresh" && menuResult.deleteAll) { - await runActionPanel("Reset Accounts", "Deleting all saved accounts", async () => { - await clearAccountsAndReset(); - console.log("Cleared saved accounts from active storage. Recovery snapshots remain available."); - }, displaySettings); + await runActionPanel( + "Reset Accounts", + "Deleting all saved accounts", + async () => { + await clearAccountsAndReset(); + console.log( + "Cleared saved accounts from active storage. Recovery snapshots remain available.", + ); + }, + displaySettings, + ); continue; } if (menuResult.mode === "manage") { - const requiresInteractiveOAuth = typeof menuResult.refreshAccountIndex === "number"; + const requiresInteractiveOAuth = + typeof menuResult.refreshAccountIndex === "number"; if (requiresInteractiveOAuth) { await handleManageAction(currentStorage, menuResult); continue; } - await runActionPanel("Applying Change", "Updating selected account", async () => { - await handleManageAction(currentStorage, menuResult); - }, displaySettings); + await runActionPanel( + "Applying Change", + "Updating selected account", + async () => { + await handleManageAction(currentStorage, menuResult); + }, + displaySettings, + ); continue; } if (menuResult.mode === "add") { @@ -4496,7 +4907,9 @@ async function runAuthLogin(args: string[]): Promise { let existingCount = refreshedStorage?.accounts.length ?? 0; let forceNewLogin = existingCount > 0; let onboardingBackupDiscoveryWarning: string | null = null; - const loadNamedBackupsForOnboarding = async (): Promise => { + const loadNamedBackupsForOnboarding = async (): Promise< + NamedBackupSummary[] + > => { if (existingCount > 0) { onboardingBackupDiscoveryWarning = null; return []; @@ -4513,9 +4926,7 @@ async function runAuthLogin(args: string[]): Promise { if (code && code !== "ENOENT") { onboardingBackupDiscoveryWarning = "Named backup discovery failed. Continuing with browser or manual sign-in only."; - console.warn( - onboardingBackupDiscoveryWarning, - ); + console.warn(onboardingBackupDiscoveryWarning); } else { onboardingBackupDiscoveryWarning = null; } @@ -4525,16 +4936,19 @@ async function runAuthLogin(args: string[]): Promise { let namedBackups = await loadNamedBackupsForOnboarding(); while (true) { const latestNamedBackup = namedBackups[0] ?? null; - const preferManualMode = loginOptions.manual || isBrowserLaunchSuppressed(); + const preferManualMode = + loginOptions.manual || isBrowserLaunchSuppressed(); const signInMode = preferManualMode ? "manual" : await promptOAuthSignInMode( - latestNamedBackup, - onboardingBackupDiscoveryWarning, - ); + latestNamedBackup, + onboardingBackupDiscoveryWarning, + ); if (signInMode === "cancel") { if (existingCount > 0) { - console.log(stylePromptText(UI_COPY.oauth.cancelledBackToMenu, "muted")); + console.log( + stylePromptText(UI_COPY.oauth.cancelledBackToMenu, "muted"), + ); continue loginFlow; } console.log("Cancelled."); @@ -4546,15 +4960,18 @@ async function runAuthLogin(args: string[]): Promise { namedBackups = await loadNamedBackupsForOnboarding(); continue; } - const restoreMode = await promptBackupRestoreMode(latestAvailableBackup); + const restoreMode = await promptBackupRestoreMode( + latestAvailableBackup, + ); if (restoreMode === "back") { namedBackups = await loadNamedBackupsForOnboarding(); continue; } - const selectedBackup = restoreMode === "manual" - ? await promptManualBackupSelection(namedBackups) - : latestAvailableBackup; + const selectedBackup = + restoreMode === "manual" + ? await promptManualBackupSelection(namedBackups) + : latestAvailableBackup; if (!selectedBackup) { namedBackups = await loadNamedBackupsForOnboarding(); continue; @@ -4603,13 +5020,16 @@ async function runAuthLogin(args: string[]): Promise { displaySettings, ); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = + error instanceof Error ? error.message : String(error); if (error instanceof StorageError) { console.error(formatStorageErrorHint(error, selectedBackup.path)); } else { console.error(`Backup restore failed: ${message}`); } - const storageAfterRestoreAttempt = await loadAccounts().catch(() => null); + const storageAfterRestoreAttempt = await loadAccounts().catch( + () => null, + ); if ((storageAfterRestoreAttempt?.accounts.length ?? 0) > 0) { continue loginFlow; } @@ -4627,13 +5047,17 @@ async function runAuthLogin(args: string[]): Promise { if (tokenResult.type !== "success") { if (isUserCancelledOAuth(tokenResult)) { if (existingCount > 0) { - console.log(stylePromptText(UI_COPY.oauth.cancelledBackToMenu, "muted")); + console.log( + stylePromptText(UI_COPY.oauth.cancelledBackToMenu, "muted"), + ); continue loginFlow; } console.log("Cancelled."); return 0; } - console.error(`Login failed: ${tokenResult.message ?? tokenResult.reason ?? "unknown error"}`); + console.error( + `Login failed: ${tokenResult.message ?? tokenResult.reason ?? "unknown error"}`, + ); return 1; } @@ -4648,7 +5072,9 @@ async function runAuthLogin(args: string[]): Promise { onboardingBackupDiscoveryWarning = null; console.log(`Added account. Total: ${count}`); if (count >= ACCOUNT_LIMITS.MAX_ACCOUNTS) { - console.log(`Reached maximum account limit (${ACCOUNT_LIMITS.MAX_ACCOUNTS}).`); + console.log( + `Reached maximum account limit (${ACCOUNT_LIMITS.MAX_ACCOUNTS}).`, + ); break; } @@ -4656,56 +5082,15 @@ async function runAuthLogin(args: string[]): Promise { if (!addAnother) break; forceNewLogin = true; } - continue loginFlow; } } async function runSwitch(args: string[]): Promise { - setStoragePath(null); - const indexArg = args[0]; - if (!indexArg) { - console.error("Missing index. Usage: codex auth switch "); - return 1; - } - const parsed = Number.parseInt(indexArg, 10); - if (!Number.isFinite(parsed) || parsed < 1) { - console.error(`Invalid index: ${indexArg}`); - return 1; - } - const targetIndex = parsed - 1; - - const storage = await loadAccounts(); - if (!storage || storage.accounts.length === 0) { - console.error("No accounts configured."); - return 1; - } - if (targetIndex < 0 || targetIndex >= storage.accounts.length) { - console.error(`Index out of range. Valid range: 1-${storage.accounts.length}`); - return 1; - } - - const account = storage.accounts[targetIndex]; - if (!account) { - console.error(`Account ${parsed} not found.`); - return 1; - } - - const { synced, wasDisabled } = await persistAndSyncSelectedAccount({ - storage, - targetIndex, - parsed, - switchReason: "rotation", + return runSwitchCommand(args, { + setStoragePath, + loadAccounts, + persistAndSyncSelectedAccount, }); - if (!synced) { - console.warn( - `Switched account ${parsed} locally, but Codex auth sync did not complete. Multi-auth routing will still use this account.`, - ); - } - - console.log( - `Switched to account ${parsed}: ${formatAccountLabel(account, targetIndex)}${wasDisabled ? " (re-enabled)" : ""}`, - ); - return 0; } async function persistAndSyncSelectedAccount({ @@ -4765,7 +5150,9 @@ async function persistAndSyncSelectedAccount({ const refreshResult = await queuedRefresh(account.refreshToken); if (refreshResult.type === "success") { const tokenAccountId = extractAccountId(refreshResult.access); - const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)); + const nextEmail = sanitizeEmail( + extractAccountEmail(refreshResult.access, refreshResult.idToken), + ); if (account.refreshToken !== refreshResult.refresh) { account.refreshToken = refreshResult.refresh; } @@ -4828,7 +5215,9 @@ async function runBest(args: string[]): Promise { const storage = await loadAccounts(); if (!storage || storage.accounts.length === 0) { if (options.json) { - console.log(JSON.stringify({ error: "No accounts configured." }, null, 2)); + console.log( + JSON.stringify({ error: "No accounts configured." }, null, 2), + ); } else { console.log("No accounts configured."); } @@ -4837,7 +5226,10 @@ async function runBest(args: string[]): Promise { const now = Date.now(); const refreshFailures = new Map(); - const liveQuotaByIndex = new Map>>(); + const liveQuotaByIndex = new Map< + number, + Awaited> + >(); const probeIdTokenByIndex = new Map(); const probeRefreshedIndices = new Set(); const probeErrors: string[] = []; @@ -4863,13 +5255,17 @@ async function runBest(args: string[]): Promise { if (account.enabled === false) continue; let probeAccessToken = account.accessToken; - let probeAccountId = account.accountId ?? extractAccountId(account.accessToken); + let probeAccountId = + account.accountId ?? extractAccountId(account.accessToken); if (!hasUsableAccessToken(account, now)) { const refreshResult = await queuedRefresh(account.refreshToken); if (refreshResult.type !== "success") { refreshFailures.set(i, { ...refreshResult, - message: normalizeFailureDetail(refreshResult.message, refreshResult.reason), + message: normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ), }); continue; } @@ -4910,7 +5306,9 @@ async function runBest(args: string[]): Promise { } if (!probeAccessToken || !probeAccountId) { - probeErrors.push(`${formatAccountLabel(account, i)}: missing accountId for live probe`); + probeErrors.push( + `${formatAccountLabel(account, i)}: missing accountId for live probe`, + ); continue; } @@ -4945,10 +5343,16 @@ async function runBest(args: string[]): Promise { if (recommendation.recommendedIndex === null) { await persistProbeChangesIfNeeded(); if (options.json) { - console.log(JSON.stringify({ - error: recommendation.reason, - ...(probeErrors.length > 0 ? { probeErrors } : {}), - }, null, 2)); + console.log( + JSON.stringify( + { + error: recommendation.reason, + ...(probeErrors.length > 0 ? { probeErrors } : {}), + }, + null, + 2, + ), + ); } else { console.log(`No best account available: ${recommendation.reason}`); printProbeNotes(); @@ -4961,7 +5365,9 @@ async function runBest(args: string[]): Promise { if (!bestAccount) { await persistProbeChangesIfNeeded(); if (options.json) { - console.log(JSON.stringify({ error: "Best account not found." }, null, 2)); + console.log( + JSON.stringify({ error: "Best account not found." }, null, 2), + ); } else { console.log("Best account not found."); } @@ -4972,7 +5378,8 @@ async function runBest(args: string[]): Promise { const currentIndex = resolveActiveIndex(storage, "codex"); if (currentIndex === bestIndex) { const shouldSyncCurrentBest = - probeRefreshedIndices.has(bestIndex) || probeIdTokenByIndex.has(bestIndex); + probeRefreshedIndices.has(bestIndex) || + probeIdTokenByIndex.has(bestIndex); let alreadyBestSynced: boolean | undefined; if (changed) { bestAccount.lastUsed = now; @@ -4990,19 +5397,31 @@ async function runBest(args: string[]): Promise { : {}), }); if (!alreadyBestSynced && !options.json) { - console.warn("Codex auth sync did not complete. Multi-auth routing will still use this account."); + console.warn( + "Codex auth sync did not complete. Multi-auth routing will still use this account.", + ); } } if (options.json) { - console.log(JSON.stringify({ - message: `Already on best account: ${formatAccountLabel(bestAccount, bestIndex)}`, - accountIndex: bestIndex + 1, - reason: recommendation.reason, - ...(alreadyBestSynced !== undefined ? { synced: alreadyBestSynced } : {}), - ...(probeErrors.length > 0 ? { probeErrors } : {}), - }, null, 2)); + console.log( + JSON.stringify( + { + message: `Already on best account: ${formatAccountLabel(bestAccount, bestIndex)}`, + accountIndex: bestIndex + 1, + reason: recommendation.reason, + ...(alreadyBestSynced !== undefined + ? { synced: alreadyBestSynced } + : {}), + ...(probeErrors.length > 0 ? { probeErrors } : {}), + }, + null, + 2, + ), + ); } else { - console.log(`Already on best account ${bestIndex + 1}: ${formatAccountLabel(bestAccount, bestIndex)}`); + console.log( + `Already on best account ${bestIndex + 1}: ${formatAccountLabel(bestAccount, bestIndex)}`, + ); console.log(`Reason: ${recommendation.reason}`); printProbeNotes(); } @@ -5020,20 +5439,30 @@ async function runBest(args: string[]): Promise { }); if (options.json) { - console.log(JSON.stringify({ - message: `Switched to best account: ${formatAccountLabel(bestAccount, targetIndex)}`, - accountIndex: parsed, - reason: recommendation.reason, - synced, - wasDisabled, - ...(probeErrors.length > 0 ? { probeErrors } : {}), - }, null, 2)); + console.log( + JSON.stringify( + { + message: `Switched to best account: ${formatAccountLabel(bestAccount, targetIndex)}`, + accountIndex: parsed, + reason: recommendation.reason, + synced, + wasDisabled, + ...(probeErrors.length > 0 ? { probeErrors } : {}), + }, + null, + 2, + ), + ); } else { - console.log(`Switched to best account ${parsed}: ${formatAccountLabel(bestAccount, targetIndex)}${wasDisabled ? " (re-enabled)" : ""}`); + console.log( + `Switched to best account ${parsed}: ${formatAccountLabel(bestAccount, targetIndex)}${wasDisabled ? " (re-enabled)" : ""}`, + ); console.log(`Reason: ${recommendation.reason}`); printProbeNotes(); if (!synced) { - console.warn("Codex auth sync did not complete. Multi-auth routing will still use this account."); + console.warn( + "Codex auth sync did not complete. Multi-auth routing will still use this account.", + ); } } return 0; @@ -5067,7 +5496,9 @@ export async function autoSyncActiveAccountToCodex(): Promise { const refreshResult = await queuedRefresh(account.refreshToken); if (refreshResult.type === "success") { const tokenAccountId = extractAccountId(refreshResult.access); - const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)); + const nextEmail = sanitizeEmail( + extractAccountEmail(refreshResult.access, refreshResult.idToken), + ); if (account.refreshToken !== refreshResult.refresh) { account.refreshToken = refreshResult.refresh; changed = true; diff --git a/lib/codex-manager/commands/switch.ts b/lib/codex-manager/commands/switch.ts new file mode 100644 index 00000000..3a6401bd --- /dev/null +++ b/lib/codex-manager/commands/switch.ts @@ -0,0 +1,78 @@ +import { formatAccountLabel } from "../../accounts.js"; +import type { AccountStorageV3 } from "../../storage.js"; + +type LoadedStorage = AccountStorageV3 | null; + +type PersistAndSyncSelectedAccount = (params: { + storage: AccountStorageV3; + targetIndex: number; + parsed: number; + switchReason: "rotation" | "best" | "restore"; +}) => Promise<{ synced: boolean; wasDisabled: boolean }>; + +export interface SwitchCommandDeps { + setStoragePath: (path: string | null) => void; + loadAccounts: () => Promise; + persistAndSyncSelectedAccount: PersistAndSyncSelectedAccount; + logError?: (message: string) => void; + logWarn?: (message: string) => void; + logInfo?: (message: string) => void; +} + +export async function runSwitchCommand( + args: string[], + deps: SwitchCommandDeps, +): Promise { + deps.setStoragePath(null); + const indexArg = args[0]; + if (!indexArg) { + (deps.logError ?? console.error)( + "Missing index. Usage: codex auth switch ", + ); + return 1; + } + + const parsed = Number.parseInt(indexArg, 10); + if (!Number.isFinite(parsed) || parsed < 1) { + (deps.logError ?? console.error)(`Invalid index: ${indexArg}`); + return 1; + } + + const targetIndex = parsed - 1; + const storage = await deps.loadAccounts(); + if (!storage || storage.accounts.length === 0) { + (deps.logError ?? console.error)("No accounts configured."); + return 1; + } + + if (targetIndex < 0 || targetIndex >= storage.accounts.length) { + (deps.logError ?? console.error)( + `Index out of range. Valid range: 1-${storage.accounts.length}`, + ); + return 1; + } + + const account = storage.accounts[targetIndex]; + if (!account) { + (deps.logError ?? console.error)(`Account ${parsed} not found.`); + return 1; + } + + const { synced, wasDisabled } = await deps.persistAndSyncSelectedAccount({ + storage, + targetIndex, + parsed, + switchReason: "rotation", + }); + + if (!synced) { + (deps.logWarn ?? console.warn)( + `Switched account ${parsed} locally, but Codex auth sync did not complete. Multi-auth routing will still use this account.`, + ); + } + + (deps.logInfo ?? console.log)( + `Switched to account ${parsed}: ${formatAccountLabel(account, targetIndex)}${wasDisabled ? " (re-enabled)" : ""}`, + ); + return 0; +} diff --git a/test/codex-manager-switch-command.test.ts b/test/codex-manager-switch-command.test.ts new file mode 100644 index 00000000..2925a430 --- /dev/null +++ b/test/codex-manager-switch-command.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from "vitest"; +import { + runSwitchCommand, + type SwitchCommandDeps, +} from "../lib/codex-manager/commands/switch.js"; +import type { AccountStorageV3 } from "../lib/storage.js"; + +function createStorage(): AccountStorageV3 { + return { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "one@example.com", + refreshToken: "refresh-token-1", + addedAt: 1, + lastUsed: 1, + }, + { + email: "two@example.com", + refreshToken: "refresh-token-2", + addedAt: 2, + lastUsed: 2, + }, + ], + }; +} + +function createDeps( + overrides: Partial = {}, +): SwitchCommandDeps { + return { + setStoragePath: vi.fn(), + loadAccounts: vi.fn(async () => createStorage()), + persistAndSyncSelectedAccount: vi.fn(async () => ({ + synced: true, + wasDisabled: false, + })), + logError: vi.fn(), + logWarn: vi.fn(), + logInfo: vi.fn(), + ...overrides, + }; +} + +describe("runSwitchCommand", () => { + it("returns an error when index is missing", async () => { + const deps = createDeps(); + + const result = await runSwitchCommand([], deps); + + expect(result).toBe(1); + expect(deps.logError).toHaveBeenCalledWith( + "Missing index. Usage: codex auth switch ", + ); + }); + + it("returns an error when index is out of range", async () => { + const deps = createDeps(); + + const result = await runSwitchCommand(["3"], deps); + + expect(result).toBe(1); + expect(deps.logError).toHaveBeenCalledWith( + "Index out of range. Valid range: 1-2", + ); + }); + + it("persists and reports the selected account", async () => { + const deps = createDeps({ + persistAndSyncSelectedAccount: vi.fn(async () => ({ + synced: false, + wasDisabled: true, + })), + }); + + const result = await runSwitchCommand(["2"], deps); + + expect(result).toBe(0); + expect(deps.persistAndSyncSelectedAccount).toHaveBeenCalledWith({ + storage: expect.objectContaining({ accounts: expect.any(Array) }), + targetIndex: 1, + parsed: 2, + switchReason: "rotation", + }); + expect(deps.logWarn).toHaveBeenCalledWith( + "Switched account 2 locally, but Codex auth sync did not complete. Multi-auth routing will still use this account.", + ); + expect(deps.logInfo).toHaveBeenCalledWith( + "Switched to account 2: Account 2 (two@example.com) (re-enabled)", + ); + }); +}); From 0abd05a0ba910a576a1af1c16dccd18af6e5fe0a Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 04:17:10 +0800 Subject: [PATCH 017/376] refactor: extract status and features handlers --- lib/codex-manager.ts | 50 +++--------- lib/codex-manager/commands/status.ts | 81 +++++++++++++++++++ test/codex-manager-status-command.test.ts | 96 +++++++++++++++++++++++ 3 files changed, 188 insertions(+), 39 deletions(-) create mode 100644 lib/codex-manager/commands/status.ts create mode 100644 test/codex-manager-status-command.test.ts diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 7f1be092..d413d116 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -6,7 +6,6 @@ import { extractAccountEmail, extractAccountId, formatAccountLabel, - formatCooldown, formatWaitTime, getAccountIdCandidates, resolveRequestAccountId, @@ -37,6 +36,10 @@ import { loadCodexCliState, } from "./codex-cli/state.js"; import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; +import { + runFeaturesCommand, + runStatusCommand, +} from "./codex-manager/commands/status.js"; import { runSwitchCommand } from "./codex-manager/commands/switch.js"; import { applyUiThemeFromDashboardSettings, @@ -431,12 +434,7 @@ const IMPLEMENTED_FEATURES: ImplementedFeature[] = [ ]; function runFeaturesReport(): number { - console.log(`Implemented features (${IMPLEMENTED_FEATURES.length})`); - console.log(""); - for (const feature of IMPLEMENTED_FEATURES) { - console.log(`${feature.id}. ${feature.name}`); - } - return 0; + return runFeaturesCommand({ implementedFeatures: IMPLEMENTED_FEATURES }); } function resolveActiveIndex( @@ -1985,38 +1983,12 @@ async function syncSelectionToCodex( } async function showAccountStatus(): Promise { - setStoragePath(null); - const storage = await loadAccounts(); - const path = getStoragePath(); - if (!storage || storage.accounts.length === 0) { - console.log("No accounts configured."); - console.log(`Storage: ${path}`); - return; - } - - const now = Date.now(); - const activeIndex = resolveActiveIndex(storage, "codex"); - console.log(`Accounts (${storage.accounts.length})`); - console.log(`Storage: ${path}`); - console.log(""); - for (let i = 0; i < storage.accounts.length; i += 1) { - const account = storage.accounts[i]; - if (!account) continue; - const label = formatAccountLabel(account, i); - const markers: string[] = []; - if (i === activeIndex) markers.push("current"); - if (account.enabled === false) markers.push("disabled"); - const rateLimit = formatRateLimitEntry(account, now, "codex"); - if (rateLimit) markers.push("rate-limited"); - const cooldown = formatCooldown(account, now); - if (cooldown) markers.push(`cooldown:${cooldown}`); - const markerLabel = markers.length > 0 ? ` [${markers.join(", ")}]` : ""; - const lastUsed = - typeof account.lastUsed === "number" && account.lastUsed > 0 - ? `used ${formatWaitTime(now - account.lastUsed)} ago` - : "never used"; - console.log(`${i + 1}. ${label}${markerLabel} ${lastUsed}`); - } + await runStatusCommand({ + setStoragePath, + loadAccounts, + resolveActiveIndex, + formatRateLimitEntry, + }); } interface HealthCheckOptions { diff --git a/lib/codex-manager/commands/status.ts b/lib/codex-manager/commands/status.ts new file mode 100644 index 00000000..db954776 --- /dev/null +++ b/lib/codex-manager/commands/status.ts @@ -0,0 +1,81 @@ +import { + formatAccountLabel, + formatCooldown, + formatWaitTime, +} from "../../accounts.js"; +import type { ModelFamily } from "../../prompts/codex.js"; +import { type AccountStorageV3, getStoragePath } from "../../storage.js"; + +type LoadedStorage = AccountStorageV3 | null; + +export interface StatusCommandDeps { + setStoragePath: (path: string | null) => void; + loadAccounts: () => Promise; + resolveActiveIndex: ( + storage: AccountStorageV3, + family?: ModelFamily, + ) => number; + formatRateLimitEntry: ( + account: AccountStorageV3["accounts"][number], + now: number, + family: ModelFamily, + ) => string | null; + getNow?: () => number; + logInfo?: (message: string) => void; +} + +export async function runStatusCommand( + deps: StatusCommandDeps, +): Promise { + deps.setStoragePath(null); + const storage = await deps.loadAccounts(); + const path = getStoragePath(); + const logInfo = deps.logInfo ?? console.log; + if (!storage || storage.accounts.length === 0) { + logInfo("No accounts configured."); + logInfo(`Storage: ${path}`); + return 0; + } + + const now = deps.getNow?.() ?? Date.now(); + const activeIndex = deps.resolveActiveIndex(storage, "codex"); + logInfo(`Accounts (${storage.accounts.length})`); + logInfo(`Storage: ${path}`); + logInfo(""); + + for (let i = 0; i < storage.accounts.length; i += 1) { + const account = storage.accounts[i]; + if (!account) continue; + const label = formatAccountLabel(account, i); + const markers: string[] = []; + if (i === activeIndex) markers.push("current"); + if (account.enabled === false) markers.push("disabled"); + const rateLimit = deps.formatRateLimitEntry(account, now, "codex"); + if (rateLimit) markers.push("rate-limited"); + const cooldown = formatCooldown(account, now); + if (cooldown) markers.push(`cooldown:${cooldown}`); + const markerLabel = markers.length > 0 ? ` [${markers.join(", ")}]` : ""; + const lastUsed = + typeof account.lastUsed === "number" && account.lastUsed > 0 + ? `used ${formatWaitTime(now - account.lastUsed)} ago` + : "never used"; + logInfo(`${i + 1}. ${label}${markerLabel} ${lastUsed}`); + } + + return 0; +} + +export interface FeaturesCommandDeps { + implementedFeatures: ReadonlyArray<{ id: number; name: string }>; + logInfo?: (message: string) => void; +} + +export function runFeaturesCommand(deps: FeaturesCommandDeps): number { + const logInfo = deps.logInfo ?? console.log; + logInfo(`Implemented features (${deps.implementedFeatures.length})`); + logInfo(""); + for (const feature of deps.implementedFeatures) { + logInfo(`${feature.id}. ${feature.name}`); + } + return 0; +} diff --git a/test/codex-manager-status-command.test.ts b/test/codex-manager-status-command.test.ts new file mode 100644 index 00000000..016791ff --- /dev/null +++ b/test/codex-manager-status-command.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from "vitest"; +import { + type FeaturesCommandDeps, + runFeaturesCommand, + runStatusCommand, + type StatusCommandDeps, +} from "../lib/codex-manager/commands/status.js"; +import type { AccountStorageV3 } from "../lib/storage.js"; + +function createStorage(): AccountStorageV3 { + return { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "one@example.com", + refreshToken: "refresh-token-1", + addedAt: 1, + lastUsed: 1, + }, + { + email: "two@example.com", + refreshToken: "refresh-token-2", + addedAt: 2, + lastUsed: 2, + enabled: false, + }, + ], + }; +} + +function createStatusDeps( + overrides: Partial = {}, +): StatusCommandDeps { + return { + setStoragePath: vi.fn(), + loadAccounts: vi.fn(async () => createStorage()), + resolveActiveIndex: vi.fn(() => 0), + formatRateLimitEntry: vi.fn(() => null), + getNow: vi.fn(() => 2_000), + logInfo: vi.fn(), + ...overrides, + }; +} + +describe("runStatusCommand", () => { + it("prints empty storage state", async () => { + const deps = createStatusDeps({ loadAccounts: vi.fn(async () => null) }); + + const result = await runStatusCommand(deps); + + expect(result).toBe(0); + expect(deps.logInfo).toHaveBeenCalledWith("No accounts configured."); + }); + + it("prints account rows with current and disabled markers", async () => { + const deps = createStatusDeps({ + formatRateLimitEntry: vi.fn((_account, _now, _family) => "limited"), + }); + + const result = await runStatusCommand(deps); + + expect(result).toBe(0); + expect(deps.logInfo).toHaveBeenCalledWith("Accounts (2)"); + expect(deps.logInfo).toHaveBeenCalledWith( + expect.stringContaining( + "1. Account 1 (one@example.com) [current, rate-limited]", + ), + ); + expect(deps.logInfo).toHaveBeenCalledWith( + expect.stringContaining( + "2. Account 2 (two@example.com) [disabled, rate-limited]", + ), + ); + }); +}); + +describe("runFeaturesCommand", () => { + it("prints the implemented feature list", () => { + const deps: FeaturesCommandDeps = { + implementedFeatures: [ + { id: 1, name: "Alpha" }, + { id: 2, name: "Beta" }, + ], + logInfo: vi.fn(), + }; + + const result = runFeaturesCommand(deps); + + expect(result).toBe(0); + expect(deps.logInfo).toHaveBeenCalledWith("Implemented features (2)"); + expect(deps.logInfo).toHaveBeenCalledWith("1. Alpha"); + expect(deps.logInfo).toHaveBeenCalledWith("2. Beta"); + }); +}); From c37beba1e01635aa7b1b36a175048cfa25d2eae3 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 04:19:46 +0800 Subject: [PATCH 018/376] refactor: extract check command wrapper --- lib/codex-manager.ts | 4 ++-- lib/codex-manager/commands/check.ts | 8 ++++++++ test/codex-manager-check-command.test.ts | 18 ++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 lib/codex-manager/commands/check.ts create mode 100644 test/codex-manager-check-command.test.ts diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index d413d116..4ed0bea2 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -36,6 +36,7 @@ import { loadCodexCliState, } from "./codex-cli/state.js"; import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; +import { runCheckCommand } from "./codex-manager/commands/check.js"; import { runFeaturesCommand, runStatusCommand, @@ -5547,8 +5548,7 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { return runSwitch(rest); } if (command === "check") { - await runHealthCheck({ liveProbe: true }); - return 0; + return runCheckCommand({ runHealthCheck }); } if (command === "features") { return runFeaturesReport(); diff --git a/lib/codex-manager/commands/check.ts b/lib/codex-manager/commands/check.ts new file mode 100644 index 00000000..69a8dc93 --- /dev/null +++ b/lib/codex-manager/commands/check.ts @@ -0,0 +1,8 @@ +export interface CheckCommandDeps { + runHealthCheck: (options: { liveProbe: boolean }) => Promise; +} + +export async function runCheckCommand(deps: CheckCommandDeps): Promise { + await deps.runHealthCheck({ liveProbe: true }); + return 0; +} diff --git a/test/codex-manager-check-command.test.ts b/test/codex-manager-check-command.test.ts new file mode 100644 index 00000000..829c8487 --- /dev/null +++ b/test/codex-manager-check-command.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it, vi } from "vitest"; +import { + type CheckCommandDeps, + runCheckCommand, +} from "../lib/codex-manager/commands/check.js"; + +describe("runCheckCommand", () => { + it("runs health check with live probing enabled", async () => { + const deps: CheckCommandDeps = { + runHealthCheck: vi.fn(async () => undefined), + }; + + const result = await runCheckCommand(deps); + + expect(result).toBe(0); + expect(deps.runHealthCheck).toHaveBeenCalledWith({ liveProbe: true }); + }); +}); From ea277b9d1e3809c6d4aaa504edde3d5e490d6249 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 04:20:06 +0800 Subject: [PATCH 019/376] fix(cli):close-repair-review-gaps --- lib/codex-manager/repair-commands.ts | 88 +++++--- lib/storage.ts | 121 +++++----- test/codex-manager-cli.test.ts | 132 ++++++++++- test/repair-commands.test.ts | 317 ++++++++++++++++++++++++++- test/storage.test.ts | 35 +++ 5 files changed, 596 insertions(+), 97 deletions(-) diff --git a/lib/codex-manager/repair-commands.ts b/lib/codex-manager/repair-commands.ts index 9835960f..8eafd7c2 100644 --- a/lib/codex-manager/repair-commands.ts +++ b/lib/codex-manager/repair-commands.ts @@ -779,6 +779,7 @@ export async function runVerifyFlagged( restored: 0, healthyFlagged: 0, stillFlagged: 0, + remainingFlagged: 0, changed: false, dryRun: options.dryRun, restore: options.restore, @@ -939,7 +940,7 @@ export async function runVerifyFlagged( } }; - let remainingFlagged = nextFlaggedAccounts.length; + let remainingFlagged = 0; if (options.restore) { if (options.dryRun) { @@ -966,6 +967,7 @@ export async function runVerifyFlagged( } } else { applyRefreshChecks(createEmptyAccountStorage()); + remainingFlagged = nextFlaggedAccounts.length; } if (options.dryRun) { @@ -1082,7 +1084,35 @@ export async function runFix( setStoragePath(null); const storage = await loadAccounts(); if (!storage || storage.accounts.length === 0) { - console.log("No accounts configured."); + if (options.json) { + console.log( + JSON.stringify( + { + command: "fix", + dryRun: options.dryRun, + liveProbe: options.live, + model: options.model, + changed: false, + summary: { + healthy: 0, + disabled: 0, + warnings: 0, + skipped: 0, + }, + recommendation: { + recommendedIndex: null, + reason: "No accounts configured.", + }, + recommendedSwitchCommand: null, + reports: [] as FixAccountReport[], + }, + null, + 2, + ), + ); + } else { + console.log("No accounts configured."); + } return 0; } const originalAccounts = storage.accounts.map((account) => structuredClone(account)); @@ -1093,7 +1123,7 @@ export async function runFix( const now = Date.now(); const activeIndex = deps.resolveActiveIndex(storage, "codex"); - let changed = false; + let accountStorageChanged = false; const reports: FixAccountReport[] = []; const refreshFailures = new Map(); const hardDisabledIndexes: number[] = []; @@ -1146,17 +1176,7 @@ export async function runFix( : "live session OK", }); continue; - } catch (error) { - const message = deps.normalizeFailureDetail( - error instanceof Error ? error.message : String(error), - undefined, - ); - reports.push({ - index: i, - label, - outcome: "warning-soft-failure", - message: `live probe failed (${message}), trying refresh fallback`, - }); + } catch { refreshAfterLiveProbeFailure = true; } } @@ -1208,7 +1228,7 @@ export async function runFix( accountIdentityChanged = true; } - if (accountChanged) changed = true; + if (accountChanged) accountStorageChanged = true; if (accountIdentityChanged && options.live && workingQuotaCache) { quotaEmailFallbackState = deps.buildQuotaEmailFallbackState(storage.accounts); quotaCacheChanged = @@ -1281,7 +1301,7 @@ export async function runFix( }); if (isHardRefreshFailure(refreshResult)) { account.enabled = false; - changed = true; + accountStorageChanged = true; hardDisabledIndexes.push(i); reports.push({ index: i, @@ -1312,7 +1332,7 @@ export async function runFix( : undefined; if (fallback && fallback.enabled === false) { fallback.enabled = true; - changed = true; + accountStorageChanged = true; const existingReport = reports.find( (report) => report.index === fallbackIndex @@ -1343,7 +1363,7 @@ export async function runFix( storage.accounts, ); - if (changed && !options.dryRun) { + if (accountStorageChanged && !options.dryRun) { await withAccountStorageTransaction(async (loadedStorage, persist) => { const nextStorage = loadedStorage ? structuredClone(loadedStorage) @@ -1353,6 +1373,8 @@ export async function runFix( }); } + const changed = accountStorageChanged || quotaCacheChanged; + if (options.json) { if (!options.dryRun && workingQuotaCache && quotaCacheChanged) { await saveQuotaCache(workingQuotaCache); @@ -1648,17 +1670,7 @@ export async function runDoctor( if (options.fix && storage && storage.accounts.length > 0) { const fixed = applyDoctorFixes(storage, deps); storageFixChanged = fixed.changed; - fixChanged = fixed.changed; fixActions = fixed.actions; - addCheck({ - key: "auto-fix", - severity: fixChanged ? "warn" : "ok", - message: fixChanged - ? options.dryRun - ? `Prepared ${fixActions.length} fix(es) (dry-run)` - : `Applied ${fixActions.length} fix(es)` - : "No safe auto-fixes needed", - }); } if (!storage || storage.accounts.length === 0) { addCheck({ @@ -1718,14 +1730,14 @@ export async function runDoctor( let placeholderEmailCount = 0; let likelyInvalidRefreshTokenCount = 0; for (const account of storage.accounts) { + if (deps.hasLikelyInvalidRefreshToken(account.refreshToken)) { + likelyInvalidRefreshTokenCount += 1; + } const email = sanitizeEmail(account.email); if (!email) continue; if (seenEmails.has(email)) duplicateEmailCount += 1; seenEmails.add(email); if (hasPlaceholderEmail(email)) placeholderEmailCount += 1; - if (deps.hasLikelyInvalidRefreshToken(account.refreshToken)) { - likelyInvalidRefreshTokenCount += 1; - } } addCheck({ key: "duplicate-email", @@ -1901,7 +1913,6 @@ export async function runDoctor( if (pendingCodexActiveSync) { const synced = await setCodexCliActiveSelection(pendingCodexActiveSync); if (synced) { - fixChanged = true; fixActions.push({ key: "codex-active-sync", message: "Synced manager active account into Codex auth state", @@ -1915,6 +1926,19 @@ export async function runDoctor( } } + if (options.fix && storage && storage.accounts.length > 0) { + fixChanged = fixActions.length > 0; + addCheck({ + key: "auto-fix", + severity: fixChanged ? "warn" : "ok", + message: fixChanged + ? options.dryRun + ? `Prepared ${fixActions.length} fix(es) (dry-run)` + : `Applied ${fixActions.length} fix(es)` + : "No safe auto-fixes needed", + }); + } + const summary = checks.reduce( (acc, check) => { acc[check.severity] += 1; diff --git a/lib/storage.ts b/lib/storage.ts index 2db2716e..9e498db1 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -767,6 +767,63 @@ async function loadFlaggedAccountsFromPath( return normalizeFlaggedStorage(data); } +async function loadFlaggedAccountsWithFallback( + path: string, + persistMigrated: (storage: FlaggedAccountStorageV1) => Promise, +): Promise { + const resetMarkerPath = getIntentionalResetMarkerPath(path); + const empty: FlaggedAccountStorageV1 = { version: 1, accounts: [] }; + + try { + const loaded = await loadFlaggedAccountsFromPath(path); + if (existsSync(resetMarkerPath)) { + return empty; + } + return loaded; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.error("Failed to load flagged account storage", { + path, + error: String(error), + }); + return empty; + } + } + + const legacyPath = getLegacyFlaggedAccountsPath(); + if (!existsSync(legacyPath)) { + return empty; + } + + try { + const legacyContent = await fs.readFile(legacyPath, "utf-8"); + const legacyData = JSON.parse(legacyContent) as unknown; + const migrated = normalizeFlaggedStorage(legacyData); + if (migrated.accounts.length > 0) { + await persistMigrated(migrated); + } + try { + await fs.unlink(legacyPath); + } catch { + // Best effort cleanup. + } + log.info("Migrated legacy flagged account storage", { + from: legacyPath, + to: path, + accounts: migrated.accounts.length, + }); + return migrated; + } catch (error) { + log.error("Failed to migrate legacy flagged account storage", { + from: legacyPath, + to: path, + error: String(error), + }); + return empty; + } +} + async function describeFlaggedSnapshot( path: string, kind: BackupSnapshotKind, @@ -2120,7 +2177,10 @@ export async function withAccountAndFlaggedStorageTransaction( active: true, }; const current = state.snapshot; - const currentFlagged = await loadFlaggedAccountsFromPath(getFlaggedAccountsPath()); + const currentFlagged = await loadFlaggedAccountsWithFallback( + getFlaggedAccountsPath(), + saveFlaggedAccountsUnlocked, + ); const persist = async ( accountStorage: AccountStorageV3, flaggedStorage: FlaggedAccountStorageV1, @@ -2166,7 +2226,10 @@ export async function withFlaggedStorageTransaction( ) => Promise, ): Promise { return withStorageLock(async () => { - const current = await loadFlaggedAccountsFromPath(getFlaggedAccountsPath()); + const current = await loadFlaggedAccountsWithFallback( + getFlaggedAccountsPath(), + saveFlaggedAccountsUnlocked, + ); let snapshot = cloneFlaggedStorageForPersistence(current); const persist = async (storage: FlaggedAccountStorageV1): Promise => { const previousStorage = cloneFlaggedStorageForPersistence(snapshot); @@ -2372,59 +2435,7 @@ function normalizeFlaggedStorage(data: unknown): FlaggedAccountStorageV1 { export async function loadFlaggedAccounts(): Promise { const path = getFlaggedAccountsPath(); - const resetMarkerPath = getIntentionalResetMarkerPath(path); - const empty: FlaggedAccountStorageV1 = { version: 1, accounts: [] }; - - try { - const content = await fs.readFile(path, "utf-8"); - const data = JSON.parse(content) as unknown; - const loaded = normalizeFlaggedStorage(data); - if (existsSync(resetMarkerPath)) { - return empty; - } - return loaded; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - log.error("Failed to load flagged account storage", { - path, - error: String(error), - }); - return empty; - } - } - - const legacyPath = getLegacyFlaggedAccountsPath(); - if (!existsSync(legacyPath)) { - return empty; - } - - try { - const legacyContent = await fs.readFile(legacyPath, "utf-8"); - const legacyData = JSON.parse(legacyContent) as unknown; - const migrated = normalizeFlaggedStorage(legacyData); - if (migrated.accounts.length > 0) { - await saveFlaggedAccounts(migrated); - } - try { - await fs.unlink(legacyPath); - } catch { - // Best effort cleanup. - } - log.info("Migrated legacy flagged account storage", { - from: legacyPath, - to: path, - accounts: migrated.accounts.length, - }); - return migrated; - } catch (error) { - log.error("Failed to migrate legacy flagged account storage", { - from: legacyPath, - to: path, - error: String(error), - }); - return empty; - } + return loadFlaggedAccountsWithFallback(path, saveFlaggedAccounts); } async function saveFlaggedAccountsUnlocked( diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index a7765c57..abe5a238 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -37,6 +37,8 @@ const loggerWarnMock = vi.fn(); const loggerErrorMock = vi.fn(); let lastAccountStorageSnapshot: unknown = null; let lastFlaggedStorageSnapshot: unknown = null; +let accountStorageState: unknown = null; +let flaggedStorageState: unknown = null; vi.mock("../lib/logger.js", () => ({ createLogger: vi.fn(() => ({ @@ -276,14 +278,18 @@ function updateLastAccountStorageSnapshot(snapshot: unknown): void { if (snapshot == null) { return; } - lastAccountStorageSnapshot = cloneValue(snapshot); + const cloned = cloneValue(snapshot); + lastAccountStorageSnapshot = cloned; + accountStorageState = cloneValue(cloned); } function updateLastFlaggedStorageSnapshot(snapshot: unknown): void { if (snapshot == null) { return; } - lastFlaggedStorageSnapshot = cloneValue(snapshot); + const cloned = cloneValue(snapshot); + lastFlaggedStorageSnapshot = cloned; + flaggedStorageState = cloneValue(cloned); } function getLastLoadedAccountSnapshot(): unknown { @@ -307,7 +313,9 @@ async function getCurrentAccountSnapshot(): Promise { updateLastAccountStorageSnapshot(current); return current; } - return getLastLoadedAccountSnapshot(); + return accountStorageState == null + ? getLastLoadedAccountSnapshot() + : cloneValue(accountStorageState); } async function getCurrentFlaggedSnapshot(): Promise { @@ -316,7 +324,9 @@ async function getCurrentFlaggedSnapshot(): Promise { updateLastFlaggedStorageSnapshot(current); return current; } - return getLastLoadedFlaggedSnapshot(); + return flaggedStorageState == null + ? getLastLoadedFlaggedSnapshot() + : cloneValue(flaggedStorageState); } function makeErrnoError(message: string, code: string): NodeJS.ErrnoException { @@ -568,10 +578,15 @@ describe("codex manager cli commands", () => { accounts: [], }); lastAccountStorageSnapshot = null; + accountStorageState = null; lastFlaggedStorageSnapshot = { version: 1, accounts: [], }; + flaggedStorageState = { + version: 1, + accounts: [], + }; withAccountStorageTransactionMock.mockImplementation( async (handler) => { const current = await getCurrentAccountSnapshot(); @@ -616,14 +631,18 @@ describe("codex manager cli commands", () => { async (storage: unknown, flaggedStorage: unknown) => { const previousSnapshot = structuredClone(snapshot); const previousFlaggedSnapshot = structuredClone(flaggedSnapshot); + accountStorageState = structuredClone(storage); await saveAccountsMock(storage); try { + flaggedStorageState = structuredClone(flaggedStorage); await saveFlaggedAccountsMock(flaggedStorage); snapshot = structuredClone(storage); flaggedSnapshot = structuredClone(flaggedStorage); updateLastAccountStorageSnapshot(snapshot); updateLastFlaggedStorageSnapshot(flaggedSnapshot); } catch (error) { + accountStorageState = structuredClone(previousSnapshot); + flaggedStorageState = structuredClone(previousFlaggedSnapshot); await saveAccountsMock(previousSnapshot); updateLastAccountStorageSnapshot(previousSnapshot); updateLastFlaggedStorageSnapshot(previousFlaggedSnapshot); @@ -644,8 +663,16 @@ describe("codex manager cli commands", () => { } : structuredClone(current); return handler(structuredClone(snapshot), async (storage: unknown) => { - await saveFlaggedAccountsMock(storage); - updateLastFlaggedStorageSnapshot(storage); + const previousSnapshot = structuredClone(snapshot); + flaggedStorageState = structuredClone(storage); + try { + await saveFlaggedAccountsMock(storage); + updateLastFlaggedStorageSnapshot(storage); + } catch (error) { + flaggedStorageState = structuredClone(previousSnapshot); + updateLastFlaggedStorageSnapshot(previousSnapshot); + throw error; + } }); }); loadDashboardDisplaySettingsMock.mockResolvedValue({ @@ -6282,6 +6309,99 @@ describe("codex manager cli commands", () => { ); }); + it("preserves concurrently added accounts during doctor --fix persistence", async () => { + const now = Date.now(); + const initialStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "doctor@example.com", + accountId: "doctor-account", + accountIdSource: "manual", + refreshToken: "doctor-refresh", + accessToken: "doctor-access", + expiresAt: now - 60_000, + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + ], + }; + let storageState = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "doctor@example.com", + accountId: "doctor-account", + accountIdSource: "manual", + refreshToken: "doctor-refresh", + accessToken: "doctor-access", + expiresAt: now - 60_000, + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + { + email: "concurrent@example.com", + accountId: "concurrent-account", + accountIdSource: "manual", + refreshToken: "concurrent-refresh", + accessToken: "concurrent-access", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }; + loadAccountsMock + .mockResolvedValueOnce(structuredClone(initialStorage)) + .mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "doctor-access-next", + refresh: "doctor-refresh-next", + expires: now + 3_600_000, + idToken: "doctor-id-next", + }); + setCodexCliActiveSelectionMock.mockResolvedValueOnce(true); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli([ + "auth", + "doctor", + "--fix", + "--json", + ]); + + expect(exitCode).toBe(0); + expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).toHaveBeenCalledWith( + expect.objectContaining({ + accounts: expect.arrayContaining([ + expect.objectContaining({ + accountId: "doctor-account", + refreshToken: "doctor-refresh-next", + accessToken: "doctor-access-next", + }), + expect.objectContaining({ + accountId: "concurrent-account", + refreshToken: "concurrent-refresh", + accessToken: "concurrent-access", + }), + ]), + }), + ); + }); + it("skips doctor --fix Codex sync when active refresh fails", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValueOnce({ diff --git a/test/repair-commands.test.ts b/test/repair-commands.test.ts index 657a997d..b8147f8c 100644 --- a/test/repair-commands.test.ts +++ b/test/repair-commands.test.ts @@ -215,8 +215,66 @@ describe("repair-commands direct deps coverage", () => { }); }); - it("runFix uses the injected token-identity applier in the direct command path", async () => { - const accountStorage = { + it("runVerifyFlagged keeps remainingFlagged in the JSON schema for empty and no-op paths", async () => { + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + loadFlaggedAccountsMock.mockResolvedValueOnce({ + version: 1, + accounts: [], + }); + + let exitCode = await runVerifyFlagged( + ["--json", "--no-restore"], + createDeps(), + ); + expect(exitCode).toBe(0); + expect( + JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")), + ).toMatchObject({ + total: 0, + remainingFlagged: 0, + changed: false, + }); + + const flaggedAccount = { + email: "flagged@example.com", + refreshToken: "flagged-refresh", + accessToken: "old-access", + expiresAt: 10, + accountId: "stored-account", + accountIdSource: "manual" as const, + lastError: "still broken", + lastUsed: 1, + }; + loadFlaggedAccountsMock.mockResolvedValueOnce({ + version: 1, + accounts: [structuredClone(flaggedAccount)], + }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "failed", + reason: "revoked", + message: "still broken", + }); + + exitCode = await runVerifyFlagged( + ["--json", "--no-restore"], + createDeps(), + ); + + expect(exitCode).toBe(0); + expect(withFlaggedStorageTransactionMock).not.toHaveBeenCalled(); + expect( + JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")), + ).toMatchObject({ + total: 1, + remainingFlagged: 1, + stillFlagged: 1, + changed: false, + }); + }); + + it("runFix uses the injected token-identity applier in the direct concurrent-write path", async () => { + const prescanStorage = { version: 3, accounts: [ { @@ -232,9 +290,35 @@ describe("repair-commands direct deps coverage", () => { activeIndex: 0, activeIndexByFamily: {}, }; + const inTransactionStorage = { + version: 3, + accounts: [ + { + email: "old@example.com", + refreshToken: "old-refresh", + accessToken: "concurrent-access", + expiresAt: 25, + accountId: "old-account", + accountIdSource: "manual" as const, + accountLabel: "Concurrent Label", + enabled: true, + }, + { + email: "beta@example.com", + refreshToken: "beta-refresh", + accessToken: "beta-access", + expiresAt: 30, + accountId: "beta-account", + accountIdSource: "manual" as const, + enabled: true, + }, + ], + activeIndex: 0, + activeIndexByFamily: {}, + }; let persistedAccountStorage: unknown; - loadAccountsMock.mockResolvedValue(structuredClone(accountStorage)); + loadAccountsMock.mockResolvedValue(structuredClone(prescanStorage)); queuedRefreshMock.mockResolvedValue({ type: "success", access: "new-access", @@ -245,7 +329,7 @@ describe("repair-commands direct deps coverage", () => { extractAccountEmailMock.mockReturnValue("fresh@example.com"); extractAccountIdMock.mockReturnValue("token-account"); withAccountStorageTransactionMock.mockImplementation(async (handler) => - handler(structuredClone(accountStorage), async (nextStorage: unknown) => { + handler(structuredClone(inTransactionStorage), async (nextStorage: unknown) => { persistedAccountStorage = nextStorage; }), ); @@ -267,12 +351,17 @@ describe("repair-commands direct deps coverage", () => { expect(persistedAccountStorage).toMatchObject({ accounts: [ expect.objectContaining({ + accountLabel: "Concurrent Label", accountId: "dep-token-account", accountIdSource: "token", accessToken: "new-access", refreshToken: "new-refresh", email: "fresh@example.com", }), + expect.objectContaining({ + accountId: "beta-account", + refreshToken: "beta-refresh", + }), ], }); expect( @@ -282,6 +371,131 @@ describe("repair-commands direct deps coverage", () => { }); }); + it("runFix keeps JSON output consistent for no-account and quota-cache-only changes", async () => { + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + loadAccountsMock.mockResolvedValueOnce(null); + let exitCode = await runFix(["--json"], createDeps()); + + expect(exitCode).toBe(0); + expect( + JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")), + ).toMatchObject({ + command: "fix", + changed: false, + summary: { + healthy: 0, + disabled: 0, + warnings: 0, + skipped: 0, + }, + reports: [], + }); + + loadQuotaCacheMock.mockResolvedValueOnce({ + byAccountId: {}, + byEmail: {}, + }); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + accounts: [ + { + email: "quota@example.com", + refreshToken: "quota-refresh", + accessToken: "quota-access", + expiresAt: Date.now() + 60_000, + accountId: "quota-account", + accountIdSource: "manual" as const, + enabled: true, + }, + ], + activeIndex: 0, + activeIndexByFamily: {}, + }); + fetchCodexQuotaSnapshotMock.mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + }); + + exitCode = await runFix( + ["--json", "--live"], + createDeps({ + hasUsableAccessToken: () => true, + updateQuotaCacheForAccount: () => true, + }), + ); + + expect(exitCode).toBe(0); + expect(withAccountStorageTransactionMock).not.toHaveBeenCalled(); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect( + JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")), + ).toMatchObject({ + command: "fix", + changed: true, + summary: { + healthy: 1, + }, + }); + }); + + it("runFix does not double-count a live probe failure followed by refresh fallback", async () => { + loadQuotaCacheMock.mockResolvedValueOnce({ + byAccountId: {}, + byEmail: {}, + }); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + accounts: [ + { + email: "fallback@example.com", + refreshToken: "refresh-fallback", + accessToken: "access-fallback", + expiresAt: Date.now() + 60_000, + accountId: "fallback-account", + accountIdSource: "manual" as const, + enabled: true, + }, + ], + activeIndex: 0, + activeIndexByFamily: {}, + }); + fetchCodexQuotaSnapshotMock + .mockRejectedValueOnce(new Error("probe unavailable")) + .mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "access-fallback-next", + refresh: "refresh-fallback-next", + expires: Date.now() + 120_000, + idToken: "id-token-fallback", + }); + extractAccountEmailMock.mockReturnValue("fallback@example.com"); + extractAccountIdMock.mockReturnValue("fallback-account"); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const exitCode = await runFix( + ["--json", "--live"], + createDeps({ hasUsableAccessToken: () => true }), + ); + + expect(exitCode).toBe(0); + const payload = JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")) as { + summary: { healthy: number; warnings: number }; + reports: Array<{ outcome: string }>; + }; + expect(payload.summary).toMatchObject({ healthy: 1, warnings: 0 }); + expect(payload.reports).toHaveLength(1); + expect(payload.reports[0]).toMatchObject({ outcome: "healthy" }); + }); + it("runDoctor uses the injected refresh-token validator in JSON diagnostics", async () => { loadAccountsMock.mockResolvedValue({ version: 3, @@ -318,4 +532,99 @@ describe("repair-commands direct deps coverage", () => { }), ); }); + + it("runDoctor checks refresh token shape even when email is missing", async () => { + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + accounts: [ + { + refreshToken: "bad-refresh-token", + accessToken: "access", + expiresAt: 100, + accountId: "doctor-account", + accountIdSource: "manual" as const, + enabled: true, + }, + ], + activeIndex: 0, + activeIndexByFamily: {}, + }); + const hasLikelyInvalidRefreshToken = vi.fn(() => true); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const exitCode = await runDoctor( + ["--json"], + createDeps({ hasLikelyInvalidRefreshToken }), + ); + + expect(exitCode).toBe(0); + expect(hasLikelyInvalidRefreshToken).toHaveBeenCalledWith("bad-refresh-token"); + expect( + JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")).checks, + ).toContainEqual( + expect.objectContaining({ + key: "refresh-token-shape", + severity: "warn", + }), + ); + }); + + it("runDoctor derives auto-fix state from the final action set", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + accounts: [ + { + email: "doctor@example.com", + refreshToken: "doctor-refresh", + accessToken: "doctor-access", + expiresAt: now - 60_000, + accountId: "doctor-account", + accountIdSource: "manual" as const, + enabled: true, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "doctor-access-next", + refresh: "doctor-refresh-next", + expires: now + 3_600_000, + idToken: "doctor-id-next", + }); + setCodexCliActiveSelectionMock.mockResolvedValueOnce(true); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const exitCode = await runDoctor( + ["--json", "--fix"], + createDeps({ + hasUsableAccessToken: () => false, + }), + ); + + expect(exitCode).toBe(0); + const payload = JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")) as { + checks: Array<{ key: string; severity: string; message: string }>; + fix: { + changed: boolean; + actions: Array<{ key: string }>; + }; + }; + expect(payload.fix.changed).toBe(true); + expect(payload.fix.actions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: "doctor-refresh" }), + expect.objectContaining({ key: "codex-active-sync" }), + ]), + ); + expect(payload.checks).toContainEqual( + expect.objectContaining({ + key: "auto-fix", + severity: "warn", + message: expect.stringMatching(/Applied \d+ fix\(es\)/), + }), + ); + }); }); diff --git a/test/storage.test.ts b/test/storage.test.ts index 674d2425..a54f82ca 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -24,6 +24,7 @@ import { saveAccounts, setStoragePath, setStoragePathDirect, + clearFlaggedAccounts, withAccountAndFlaggedStorageTransaction, withAccountStorageTransaction, withFlaggedStorageTransaction, @@ -1012,6 +1013,39 @@ describe("storage", () => { ); }); + it("treats missing flagged storage as empty inside flagged transactions", async () => { + const now = Date.now(); + await saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + accountId: "acct-existing", + email: "existing@example.com", + refreshToken: "refresh-existing", + addedAt: now - 10_000, + lastUsed: now - 10_000, + }, + ], + }); + await clearFlaggedAccounts(); + + await expect( + withFlaggedStorageTransaction(async (current) => { + expect(current).toEqual({ version: 1, accounts: [] }); + }), + ).resolves.toBeUndefined(); + + await expect( + withAccountAndFlaggedStorageTransaction( + async (_current, _persist, currentFlagged) => { + expect(currentFlagged).toEqual({ version: 1, accounts: [] }); + }, + ), + ).resolves.toBeUndefined(); + }); + it("retries transient flagged storage rename and succeeds", async () => { const now = Date.now(); await saveFlaggedAccounts({ @@ -1101,6 +1135,7 @@ describe("storage", () => { it("should fail export when no accounts exist", async () => { const { exportAccounts } = await import("../lib/storage.js"); setStoragePathDirect(testStoragePath); + await clearAccounts(); await expect(exportAccounts(exportPath)).rejects.toThrow( /No accounts to export/, ); From 8368528846bdc26f3ac45596e10c8dc53d1d5880 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 04:27:57 +0800 Subject: [PATCH 020/376] refactor: extract report command --- lib/codex-manager.ts | 262 +---------------- lib/codex-manager/commands/report.ts | 339 ++++++++++++++++++++++ test/codex-manager-report-command.test.ts | 97 +++++++ 3 files changed, 447 insertions(+), 251 deletions(-) create mode 100644 lib/codex-manager/commands/report.ts create mode 100644 test/codex-manager-report-command.test.ts diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 4ed0bea2..4f7b6825 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -1,5 +1,4 @@ import { existsSync, promises as fs } from "node:fs"; -import { dirname, resolve } from "node:path"; import { stdin as input, stdout as output } from "node:process"; import { createInterface } from "node:readline/promises"; import { @@ -37,6 +36,7 @@ import { } from "./codex-cli/state.js"; import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; import { runCheckCommand } from "./codex-manager/commands/check.js"; +import { runReportCommand } from "./codex-manager/commands/report.js"; import { runFeaturesCommand, runStatusCommand, @@ -2275,13 +2275,6 @@ interface FixCliOptions { model: string; } -interface ReportCliOptions { - live: boolean; - json: boolean; - model: string; - outPath?: string; -} - interface VerifyFlaggedCliOptions { dryRun: boolean; json: boolean; @@ -2572,79 +2565,6 @@ function parseDoctorArgs(args: string[]): ParsedArgsResult { return { ok: true, options }; } -function printReportUsage(): void { - console.log( - [ - "Usage:", - " codex auth report [--live] [--json] [--model ] [--out ]", - "", - "Options:", - " --live, -l Probe live quota headers via Codex backend", - " --json, -j Print machine-readable JSON output", - " --model, -m Probe model for live mode (default: gpt-5-codex)", - " --out Write JSON report to a file path", - ].join("\n"), - ); -} - -function parseReportArgs(args: string[]): ParsedArgsResult { - const options: ReportCliOptions = { - live: false, - json: false, - model: "gpt-5-codex", - }; - - for (let i = 0; i < args.length; i += 1) { - const arg = args[i]; - if (!arg) continue; - if (arg === "--live" || arg === "-l") { - options.live = true; - continue; - } - if (arg === "--json" || arg === "-j") { - options.json = true; - continue; - } - if (arg === "--model" || arg === "-m") { - const value = args[i + 1]; - if (!value) { - return { ok: false, message: "Missing value for --model" }; - } - options.model = value; - i += 1; - continue; - } - if (arg.startsWith("--model=")) { - const value = arg.slice("--model=".length).trim(); - if (!value) { - return { ok: false, message: "Missing value for --model" }; - } - options.model = value; - continue; - } - if (arg === "--out") { - const value = args[i + 1]; - if (!value) { - return { ok: false, message: "Missing value for --out" }; - } - options.outPath = value; - i += 1; - continue; - } - if (arg.startsWith("--out=")) { - const value = arg.slice("--out=".length).trim(); - if (!value) { - return { ok: false, message: "Missing value for --out" }; - } - options.outPath = value; - continue; - } - return { ok: false, message: `Unknown option: ${arg}` }; - } - - return { ok: true, options }; -} - function serializeForecastResults( results: ForecastAccountResult[], liveQuotaByIndex: Map< @@ -2946,175 +2866,6 @@ async function runForecast(args: string[]): Promise { return 0; } -async function runReport(args: string[]): Promise { - if (args.includes("--help") || args.includes("-h")) { - printReportUsage(); - return 0; - } - - const parsedArgs = parseReportArgs(args); - if (!parsedArgs.ok) { - console.error(parsedArgs.message); - printReportUsage(); - return 1; - } - const options = parsedArgs.options; - - setStoragePath(null); - const storagePath = getStoragePath(); - const storage = await loadAccounts(); - const now = Date.now(); - const accountCount = storage?.accounts.length ?? 0; - const activeIndex = storage ? resolveActiveIndex(storage, "codex") : 0; - const refreshFailures = new Map(); - const liveQuotaByIndex = new Map< - number, - Awaited> - >(); - const probeErrors: string[] = []; - - if (storage && options.live) { - for (let i = 0; i < storage.accounts.length; i += 1) { - const account = storage.accounts[i]; - if (!account || account.enabled === false) continue; - - const refreshResult = await queuedRefresh(account.refreshToken); - if (refreshResult.type !== "success") { - refreshFailures.set(i, { - ...refreshResult, - message: normalizeFailureDetail( - refreshResult.message, - refreshResult.reason, - ), - }); - continue; - } - - const accountId = - account.accountId ?? extractAccountId(refreshResult.access); - if (!accountId) { - probeErrors.push( - `${formatAccountLabel(account, i)}: missing accountId for live probe`, - ); - continue; - } - - try { - const liveQuota = await fetchCodexQuotaSnapshot({ - accountId, - accessToken: refreshResult.access, - model: options.model, - }); - liveQuotaByIndex.set(i, liveQuota); - } catch (error) { - const message = normalizeFailureDetail( - error instanceof Error ? error.message : String(error), - undefined, - ); - probeErrors.push(`${formatAccountLabel(account, i)}: ${message}`); - } - } - } - - const forecastResults = storage - ? evaluateForecastAccounts( - storage.accounts.map((account, index) => ({ - index, - account, - isCurrent: index === activeIndex, - now, - refreshFailure: refreshFailures.get(index), - liveQuota: liveQuotaByIndex.get(index), - })), - ) - : []; - const forecastSummary = summarizeForecast(forecastResults); - const recommendation = recommendForecastAccount(forecastResults); - const enabledCount = storage - ? storage.accounts.filter((account) => account.enabled !== false).length - : 0; - const disabledCount = Math.max(0, accountCount - enabledCount); - const coolingCount = storage - ? storage.accounts.filter( - (account) => - typeof account.coolingDownUntil === "number" && - account.coolingDownUntil > now, - ).length - : 0; - const rateLimitedCount = storage - ? storage.accounts.filter( - (account) => !!formatRateLimitEntry(account, now, "codex"), - ).length - : 0; - - const report = { - command: "report", - generatedAt: new Date(now).toISOString(), - storagePath, - model: options.model, - liveProbe: options.live, - accounts: { - total: accountCount, - enabled: enabledCount, - disabled: disabledCount, - coolingDown: coolingCount, - rateLimited: rateLimitedCount, - }, - activeIndex: accountCount > 0 ? activeIndex + 1 : null, - forecast: { - summary: forecastSummary, - recommendation, - probeErrors, - accounts: serializeForecastResults( - forecastResults, - liveQuotaByIndex, - refreshFailures, - ), - }, - }; - - if (options.outPath) { - const outputPath = resolve(process.cwd(), options.outPath); - await fs.mkdir(dirname(outputPath), { recursive: true }); - await fs.writeFile( - outputPath, - `${JSON.stringify(report, null, 2)}\n`, - "utf-8", - ); - } - - if (options.json) { - console.log(JSON.stringify(report, null, 2)); - return 0; - } - - console.log(`Report generated at ${report.generatedAt}`); - console.log(`Storage: ${report.storagePath}`); - console.log( - `Accounts: ${report.accounts.total} total (${report.accounts.enabled} enabled, ${report.accounts.disabled} disabled, ${report.accounts.coolingDown} cooling, ${report.accounts.rateLimited} rate-limited)`, - ); - if (report.activeIndex !== null) { - console.log(`Active account: ${report.activeIndex}`); - } - console.log( - `Forecast: ${report.forecast.summary.ready} ready, ${report.forecast.summary.delayed} delayed, ${report.forecast.summary.unavailable} unavailable`, - ); - if (report.forecast.recommendation.recommendedIndex !== null) { - console.log( - `Recommendation: account ${report.forecast.recommendation.recommendedIndex + 1} (${report.forecast.recommendation.reason})`, - ); - } else { - console.log(`Recommendation: ${report.forecast.recommendation.reason}`); - } - if (options.outPath) { - console.log(`Report written: ${resolve(process.cwd(), options.outPath)}`); - } - if (report.forecast.probeErrors.length > 0) { - console.log(`Probe notes: ${report.forecast.probeErrors.length}`); - } - return 0; -} - type FixOutcome = | "healthy" | "disabled-hard-failure" @@ -5563,7 +5314,16 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { return runBest(rest); } if (command === "report") { - return runReport(rest); + return runReportCommand(rest, { + setStoragePath, + getStoragePath, + loadAccounts, + resolveActiveIndex, + queuedRefresh, + fetchCodexQuotaSnapshot, + formatRateLimitEntry, + normalizeFailureDetail, + }); } if (command === "fix") { return runFix(rest); diff --git a/lib/codex-manager/commands/report.ts b/lib/codex-manager/commands/report.ts new file mode 100644 index 00000000..388666c8 --- /dev/null +++ b/lib/codex-manager/commands/report.ts @@ -0,0 +1,339 @@ +import { promises as fs } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { extractAccountId, formatAccountLabel } from "../../accounts.js"; +import { + evaluateForecastAccounts, + type ForecastAccountResult, + recommendForecastAccount, + summarizeForecast, +} from "../../forecast.js"; +import { + type CodexQuotaSnapshot, + formatQuotaSnapshotLine, +} from "../../quota-probe.js"; +import type { AccountStorageV3 } from "../../storage.js"; +import type { TokenFailure, TokenResult } from "../../types.js"; + +interface ReportCliOptions { + live: boolean; + json: boolean; + model: string; + outPath?: string; +} + +type ParsedArgsResult = + | { ok: true; options: T } + | { ok: false; message: string }; + +export interface ReportCommandDeps { + setStoragePath: (path: string | null) => void; + getStoragePath: () => string; + loadAccounts: () => Promise; + resolveActiveIndex: (storage: AccountStorageV3, family?: "codex") => number; + queuedRefresh: (refreshToken: string) => Promise; + fetchCodexQuotaSnapshot: (input: { + accountId: string; + accessToken: string; + model: string; + }) => Promise; + formatRateLimitEntry: ( + account: AccountStorageV3["accounts"][number], + now: number, + family: "codex", + ) => string | null; + normalizeFailureDetail: ( + message: string | undefined, + reason: string | undefined, + ) => string; + logInfo?: (message: string) => void; + logError?: (message: string) => void; + getNow?: () => number; + getCwd?: () => string; + writeFile?: (path: string, contents: string) => Promise; +} + +function printReportUsage(logInfo: (message: string) => void): void { + logInfo( + [ + "Usage: codex auth report [--live] [--json] [--model MODEL] [--out PATH]", + "", + "Options:", + " --live, -l Probe live quota headers via Codex backend", + " --json, -j Print machine-readable JSON output", + " --model, -m Probe model for live mode (default: gpt-5-codex)", + " --out Write JSON report to a file path", + ].join("\n"), + ); +} + +function parseReportArgs(args: string[]): ParsedArgsResult { + const options: ReportCliOptions = { + live: false, + json: false, + model: "gpt-5-codex", + }; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (!arg) continue; + if (arg === "--live" || arg === "-l") { + options.live = true; + continue; + } + if (arg === "--json" || arg === "-j") { + options.json = true; + continue; + } + if (arg === "--model" || arg === "-m") { + const value = args[i + 1]; + if (!value) return { ok: false, message: "Missing value for --model" }; + options.model = value; + i += 1; + continue; + } + if (arg.startsWith("--model=")) { + const value = arg.slice("--model=".length).trim(); + if (!value) return { ok: false, message: "Missing value for --model" }; + options.model = value; + continue; + } + if (arg === "--out") { + const value = args[i + 1]; + if (!value) return { ok: false, message: "Missing value for --out" }; + options.outPath = value; + i += 1; + continue; + } + if (arg.startsWith("--out=")) { + const value = arg.slice("--out=".length).trim(); + if (!value) return { ok: false, message: "Missing value for --out" }; + options.outPath = value; + continue; + } + return { ok: false, message: `Unknown option: ${arg}` }; + } + + return { ok: true, options }; +} + +function serializeForecastResults( + results: ForecastAccountResult[], + liveQuotaByIndex: Map, + refreshFailures: Map, +): Array<{ + index: number; + label: string; + isCurrent: boolean; + availability: ForecastAccountResult["availability"]; + riskScore: number; + riskLevel: ForecastAccountResult["riskLevel"]; + waitMs: number; + reasons: string[]; + liveQuota?: { + status: number; + planType?: string; + activeLimit?: number; + model: string; + summary: string; + }; + refreshFailure?: TokenFailure; +}> { + return results.map((result) => { + const liveQuota = liveQuotaByIndex.get(result.index); + return { + index: result.index, + label: result.label, + isCurrent: result.isCurrent, + availability: result.availability, + riskScore: result.riskScore, + riskLevel: result.riskLevel, + waitMs: result.waitMs, + reasons: result.reasons, + liveQuota: liveQuota + ? { + status: liveQuota.status, + planType: liveQuota.planType, + activeLimit: liveQuota.activeLimit, + model: liveQuota.model, + summary: formatQuotaSnapshotLine(liveQuota), + } + : undefined, + refreshFailure: refreshFailures.get(result.index), + }; + }); +} + +async function defaultWriteFile(path: string, contents: string): Promise { + await fs.mkdir(dirname(path), { recursive: true }); + await fs.writeFile(path, contents, "utf-8"); +} + +export async function runReportCommand( + args: string[], + deps: ReportCommandDeps, +): Promise { + const logInfo = deps.logInfo ?? console.log; + const logError = deps.logError ?? console.error; + if (args.includes("--help") || args.includes("-h")) { + printReportUsage(logInfo); + return 0; + } + + const parsedArgs = parseReportArgs(args); + if (!parsedArgs.ok) { + logError(parsedArgs.message); + printReportUsage(logInfo); + return 1; + } + const options = parsedArgs.options; + + deps.setStoragePath(null); + const storagePath = deps.getStoragePath(); + const storage = await deps.loadAccounts(); + const now = deps.getNow?.() ?? Date.now(); + const accountCount = storage?.accounts.length ?? 0; + const activeIndex = storage ? deps.resolveActiveIndex(storage, "codex") : 0; + const refreshFailures = new Map(); + const liveQuotaByIndex = new Map(); + const probeErrors: string[] = []; + + if (storage && options.live) { + for (let i = 0; i < storage.accounts.length; i += 1) { + const account = storage.accounts[i]; + if (!account || account.enabled === false) continue; + + const refreshResult = await deps.queuedRefresh(account.refreshToken); + if (refreshResult.type !== "success") { + refreshFailures.set(i, { + ...refreshResult, + message: deps.normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ), + }); + continue; + } + + const accountId = + account.accountId ?? extractAccountId(refreshResult.access); + if (!accountId) { + probeErrors.push( + `${formatAccountLabel(account, i)}: missing accountId for live probe`, + ); + continue; + } + + try { + const liveQuota = await deps.fetchCodexQuotaSnapshot({ + accountId, + accessToken: refreshResult.access, + model: options.model, + }); + liveQuotaByIndex.set(i, liveQuota); + } catch (error) { + const message = deps.normalizeFailureDetail( + error instanceof Error ? error.message : String(error), + undefined, + ); + probeErrors.push(`${formatAccountLabel(account, i)}: ${message}`); + } + } + } + + const forecastResults = storage + ? evaluateForecastAccounts( + storage.accounts.map((account, index) => ({ + index, + account, + isCurrent: index === activeIndex, + now, + refreshFailure: refreshFailures.get(index), + liveQuota: liveQuotaByIndex.get(index), + })), + ) + : []; + const forecastSummary = summarizeForecast(forecastResults); + const recommendation = recommendForecastAccount(forecastResults); + const enabledCount = storage + ? storage.accounts.filter((account) => account.enabled !== false).length + : 0; + const disabledCount = Math.max(0, accountCount - enabledCount); + const coolingCount = storage + ? storage.accounts.filter( + (account) => + typeof account.coolingDownUntil === "number" && + account.coolingDownUntil > now, + ).length + : 0; + const rateLimitedCount = storage + ? storage.accounts.filter( + (account) => !!deps.formatRateLimitEntry(account, now, "codex"), + ).length + : 0; + + const report = { + command: "report", + generatedAt: new Date(now).toISOString(), + storagePath, + model: options.model, + liveProbe: options.live, + accounts: { + total: accountCount, + enabled: enabledCount, + disabled: disabledCount, + coolingDown: coolingCount, + rateLimited: rateLimitedCount, + }, + activeIndex: accountCount > 0 ? activeIndex + 1 : null, + forecast: { + summary: forecastSummary, + recommendation, + probeErrors, + accounts: serializeForecastResults( + forecastResults, + liveQuotaByIndex, + refreshFailures, + ), + }, + }; + + const cwd = deps.getCwd?.() ?? process.cwd(); + if (options.outPath) { + const outputPath = resolve(cwd, options.outPath); + await (deps.writeFile ?? defaultWriteFile)( + outputPath, + `${JSON.stringify(report, null, 2)}\n`, + ); + } + + if (options.json) { + logInfo(JSON.stringify(report, null, 2)); + return 0; + } + + logInfo(`Report generated at ${report.generatedAt}`); + logInfo(`Storage: ${report.storagePath}`); + logInfo( + `Accounts: ${report.accounts.total} total (${report.accounts.enabled} enabled, ${report.accounts.disabled} disabled, ${report.accounts.coolingDown} cooling, ${report.accounts.rateLimited} rate-limited)`, + ); + if (report.activeIndex !== null) { + logInfo(`Active account: ${report.activeIndex}`); + } + logInfo( + `Forecast: ${report.forecast.summary.ready} ready, ${report.forecast.summary.delayed} delayed, ${report.forecast.summary.unavailable} unavailable`, + ); + if (report.forecast.recommendation.recommendedIndex !== null) { + logInfo( + `Recommendation: account ${report.forecast.recommendation.recommendedIndex + 1} (${report.forecast.recommendation.reason})`, + ); + } else { + logInfo(`Recommendation: ${report.forecast.recommendation.reason}`); + } + if (options.outPath) { + logInfo(`Report written: ${resolve(cwd, options.outPath)}`); + } + if (report.forecast.probeErrors.length > 0) { + logInfo(`Probe notes: ${report.forecast.probeErrors.length}`); + } + return 0; +} diff --git a/test/codex-manager-report-command.test.ts b/test/codex-manager-report-command.test.ts new file mode 100644 index 00000000..c50c7e83 --- /dev/null +++ b/test/codex-manager-report-command.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from "vitest"; +import { + type ReportCommandDeps, + runReportCommand, +} from "../lib/codex-manager/commands/report.js"; +import type { AccountStorageV3 } from "../lib/storage.js"; + +function createStorage(): AccountStorageV3 { + return { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "one@example.com", + refreshToken: "refresh-token-1", + accessToken: "access-token-1", + expiresAt: 10, + addedAt: 1, + lastUsed: 1, + enabled: true, + }, + ], + }; +} + +function createDeps( + overrides: Partial = {}, +): ReportCommandDeps { + return { + setStoragePath: vi.fn(), + getStoragePath: vi.fn(() => "/mock/openai-codex-accounts.json"), + loadAccounts: vi.fn(async () => createStorage()), + resolveActiveIndex: vi.fn(() => 0), + queuedRefresh: vi.fn(async () => ({ + type: "success", + access: "access-token-1", + refresh: "refresh-token-1", + expires: 100, + idToken: "id-token-1", + })), + fetchCodexQuotaSnapshot: vi.fn(async () => ({ + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + })), + formatRateLimitEntry: vi.fn(() => null), + normalizeFailureDetail: vi.fn((message) => message ?? "unknown"), + logInfo: vi.fn(), + logError: vi.fn(), + getNow: vi.fn(() => 1_000), + getCwd: vi.fn(() => "/repo"), + writeFile: vi.fn(async () => undefined), + ...overrides, + }; +} + +describe("runReportCommand", () => { + it("prints usage for help", async () => { + const deps = createDeps(); + + const result = await runReportCommand(["--help"], deps); + + expect(result).toBe(0); + expect(deps.logInfo).toHaveBeenCalledWith( + expect.stringContaining("Usage: codex auth report"), + ); + }); + + it("rejects invalid options", async () => { + const deps = createDeps(); + + const result = await runReportCommand(["--bogus"], deps); + + expect(result).toBe(1); + expect(deps.logError).toHaveBeenCalledWith("Unknown option: --bogus"); + }); + + it("writes json report output when requested", async () => { + const deps = createDeps(); + + const result = await runReportCommand( + ["--json", "--out", "report.json"], + deps, + ); + + expect(result).toBe(0); + expect(deps.writeFile).toHaveBeenCalledWith( + expect.stringContaining("report.json"), + expect.stringContaining('"command": "report"'), + ); + expect(deps.logInfo).toHaveBeenCalledWith( + expect.stringContaining('"forecast"'), + ); + }); +}); From c072da89657acc1ac7c624436605ff217b4532df Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 04:29:28 +0800 Subject: [PATCH 021/376] fix(status): inject storage path for tests --- lib/codex-manager.ts | 1 + lib/codex-manager/commands/status.ts | 5 +++-- test/codex-manager-status-command.test.ts | 5 +++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index d413d116..5c26f290 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -1985,6 +1985,7 @@ async function syncSelectionToCodex( async function showAccountStatus(): Promise { await runStatusCommand({ setStoragePath, + getStoragePath, loadAccounts, resolveActiveIndex, formatRateLimitEntry, diff --git a/lib/codex-manager/commands/status.ts b/lib/codex-manager/commands/status.ts index db954776..4851795b 100644 --- a/lib/codex-manager/commands/status.ts +++ b/lib/codex-manager/commands/status.ts @@ -4,12 +4,13 @@ import { formatWaitTime, } from "../../accounts.js"; import type { ModelFamily } from "../../prompts/codex.js"; -import { type AccountStorageV3, getStoragePath } from "../../storage.js"; +import type { AccountStorageV3 } from "../../storage.js"; type LoadedStorage = AccountStorageV3 | null; export interface StatusCommandDeps { setStoragePath: (path: string | null) => void; + getStoragePath: () => string | null; loadAccounts: () => Promise; resolveActiveIndex: ( storage: AccountStorageV3, @@ -29,7 +30,7 @@ export async function runStatusCommand( ): Promise { deps.setStoragePath(null); const storage = await deps.loadAccounts(); - const path = getStoragePath(); + const path = deps.getStoragePath(); const logInfo = deps.logInfo ?? console.log; if (!storage || storage.accounts.length === 0) { logInfo("No accounts configured."); diff --git a/test/codex-manager-status-command.test.ts b/test/codex-manager-status-command.test.ts index 016791ff..dc189228 100644 --- a/test/codex-manager-status-command.test.ts +++ b/test/codex-manager-status-command.test.ts @@ -35,6 +35,7 @@ function createStatusDeps( ): StatusCommandDeps { return { setStoragePath: vi.fn(), + getStoragePath: vi.fn(() => "/tmp/codex.json"), loadAccounts: vi.fn(async () => createStorage()), resolveActiveIndex: vi.fn(() => 0), formatRateLimitEntry: vi.fn(() => null), @@ -51,7 +52,9 @@ describe("runStatusCommand", () => { const result = await runStatusCommand(deps); expect(result).toBe(0); + expect(deps.getStoragePath).toHaveBeenCalledTimes(1); expect(deps.logInfo).toHaveBeenCalledWith("No accounts configured."); + expect(deps.logInfo).toHaveBeenCalledWith("Storage: /tmp/codex.json"); }); it("prints account rows with current and disabled markers", async () => { @@ -62,7 +65,9 @@ describe("runStatusCommand", () => { const result = await runStatusCommand(deps); expect(result).toBe(0); + expect(deps.getStoragePath).toHaveBeenCalledTimes(1); expect(deps.logInfo).toHaveBeenCalledWith("Accounts (2)"); + expect(deps.logInfo).toHaveBeenCalledWith("Storage: /tmp/codex.json"); expect(deps.logInfo).toHaveBeenCalledWith( expect.stringContaining( "1. Account 1 (one@example.com) [current, rate-limited]", From 9ac02b69f0129aa2741533f11c6b784cadee3005 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 04:30:41 +0800 Subject: [PATCH 022/376] test(check): cover wrapper rejection path --- test/codex-manager-check-command.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/codex-manager-check-command.test.ts b/test/codex-manager-check-command.test.ts index 829c8487..ecd753ab 100644 --- a/test/codex-manager-check-command.test.ts +++ b/test/codex-manager-check-command.test.ts @@ -13,6 +13,20 @@ describe("runCheckCommand", () => { const result = await runCheckCommand(deps); expect(result).toBe(0); + expect(deps.runHealthCheck).toHaveBeenCalledTimes(1); + expect(deps.runHealthCheck).toHaveBeenCalledWith({ liveProbe: true }); + }); + + it("propagates rejection from runHealthCheck", async () => { + const error = new Error("probe failed"); + const deps: CheckCommandDeps = { + runHealthCheck: vi.fn(async () => { + throw error; + }), + }; + + await expect(runCheckCommand(deps)).rejects.toThrow("probe failed"); + expect(deps.runHealthCheck).toHaveBeenCalledTimes(1); expect(deps.runHealthCheck).toHaveBeenCalledWith({ liveProbe: true }); }); }); From 586282618460f4be9ec8e6c841bddd15c81c7811 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 04:31:19 +0800 Subject: [PATCH 023/376] test: cover list and status cli output --- test/codex-manager-cli.test.ts | 632 +++++++++++++++++++++++---------- 1 file changed, 436 insertions(+), 196 deletions(-) diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 930cf8fb..6a6a824d 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -96,7 +96,10 @@ vi.mock("../lib/accounts.js", () => ({ tokenId: string | undefined, ) => { if (!storedAccountId) return tokenId; - if (currentAccountIdSource === "org" || currentAccountIdSource === "manual") { + if ( + currentAccountIdSource === "org" || + currentAccountIdSource === "manual" + ) { return storedAccountId; } return tokenId ?? storedAccountId; @@ -107,10 +110,16 @@ vi.mock("../lib/accounts.js", () => ({ ), selectBestAccountCandidate: vi.fn(() => null), shouldUpdateAccountIdFromToken: vi.fn( - (currentAccountIdSource: string | undefined, currentAccountId: string | undefined) => { + ( + currentAccountIdSource: string | undefined, + currentAccountId: string | undefined, + ) => { if (!currentAccountId) return true; if (!currentAccountIdSource) return true; - return currentAccountIdSource === "token" || currentAccountIdSource === "id_token"; + return ( + currentAccountIdSource === "token" || + currentAccountIdSource === "id_token" + ); }, ), })); @@ -499,33 +508,31 @@ describe("codex manager cli commands", () => { version: 1, accounts: [], }); - withAccountStorageTransactionMock.mockImplementation( - async (handler) => { - const current = await loadAccountsMock(); - return handler( - current == null - ? { + withAccountStorageTransactionMock.mockImplementation(async (handler) => { + const current = await loadAccountsMock(); + return handler( + current == null + ? { version: 3, accounts: [], activeIndex: 0, activeIndexByFamily: {}, } - : structuredClone(current), - async (storage: unknown) => saveAccountsMock(storage), - ); - }, - ); + : structuredClone(current), + async (storage: unknown) => saveAccountsMock(storage), + ); + }); withAccountAndFlaggedStorageTransactionMock.mockImplementation( async (handler) => { const current = await loadAccountsMock(); let snapshot = current == null ? { - version: 3, - accounts: [], - activeIndex: 0, - activeIndexByFamily: {}, - } + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + } : structuredClone(current); return handler( structuredClone(snapshot), @@ -576,7 +583,9 @@ describe("codex manager cli commands", () => { const { formatBackupSavedAt } = await import("../lib/codex-manager.js"); try { - expect(formatBackupSavedAt(1_710_000_000_000)).toBe("Localized Saved Time"); + expect(formatBackupSavedAt(1_710_000_000_000)).toBe( + "Localized Saved Time", + ); expect(localeSpy).toHaveBeenCalledWith(undefined, { month: "short", day: "numeric", @@ -589,6 +598,62 @@ describe("codex manager cli commands", () => { } }); + it("prints empty account status for auth list", async () => { + loadAccountsMock.mockResolvedValueOnce(null); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "list"]); + + expect(exitCode).toBe(0); + expect(logSpy).toHaveBeenCalledWith("No accounts configured."); + expect(logSpy).toHaveBeenCalledWith( + "Storage: /mock/openai-codex-accounts.json", + ); + expect(setStoragePathMock).toHaveBeenCalledWith(null); + }); + + it("prints populated account status for auth status", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "active@example.com", + refreshToken: "refresh-active", + addedAt: now - 2_000, + lastUsed: now - 1_000, + coolingDownUntil: now + 60_000, + }, + { + email: "disabled@example.com", + refreshToken: "refresh-disabled", + addedAt: now - 2_000, + lastUsed: now - 500, + enabled: false, + rateLimits: { + codex_rpm: { remaining: 0, resetAt: now + 60_000 }, + }, + }, + ], + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "status"]); + + expect(exitCode).toBe(0); + expect(logSpy).toHaveBeenCalledWith("Accounts (2)"); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("1. 1. active@example.com [current]"), + ); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("2. 2. disabled@example.com [disabled]"), + ); + }); + it("runs forecast in json mode", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValueOnce({ @@ -859,9 +924,7 @@ describe("codex manager cli commands", () => { expect(logSpy.mock.calls[0]?.[0]).toBe("Implemented features (41)"); expect( logSpy.mock.calls.some((call) => - String(call[0]).includes( - "41. Auto-switch to best account command", - ), + String(call[0]).includes("41. Auto-switch to best account command"), ), ).toBe(true); }); @@ -930,10 +993,17 @@ describe("codex manager cli commands", () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "best", "--model", "gpt-5.1"]); + const exitCode = await runCodexMultiAuthCli([ + "auth", + "best", + "--model", + "gpt-5.1", + ]); expect(exitCode).toBe(1); - expect(errorSpy).toHaveBeenCalledWith("--model requires --live for codex auth best"); + expect(errorSpy).toHaveBeenCalledWith( + "--model requires --live for codex auth best", + ); expect(loadAccountsMock).not.toHaveBeenCalled(); expect(saveAccountsMock).not.toHaveBeenCalled(); expect(fetchCodexQuotaSnapshotMock).not.toHaveBeenCalled(); @@ -1074,7 +1144,9 @@ describe("codex manager cli commands", () => { ]); expect(exitCode).toBe(0); - expect(withAccountAndFlaggedStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(withAccountAndFlaggedStorageTransactionMock).toHaveBeenCalledTimes( + 1, + ); expect(saveAccountsMock).toHaveBeenCalledWith( expect.objectContaining({ accounts: expect.arrayContaining([ @@ -1131,7 +1203,9 @@ describe("codex manager cli commands", () => { ]); expect(exitCode).toBe(0); - expect(withAccountAndFlaggedStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(withAccountAndFlaggedStorageTransactionMock).toHaveBeenCalledTimes( + 1, + ); const savedStorage = saveAccountsMock.mock.calls.at(-1)?.[0]; expect(savedStorage).toEqual( expect.objectContaining({ @@ -1199,7 +1273,11 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); const savedStorage = saveAccountsMock.mock.calls.at(-1)?.[0] as { - accounts: Array<{ accountId?: string; accountIdSource?: string; refreshToken?: string }>; + accounts: Array<{ + accountId?: string; + accountIdSource?: string; + refreshToken?: string; + }>; }; expect(savedStorage.accounts[0]).toEqual( expect.objectContaining({ @@ -1556,21 +1634,19 @@ describe("codex manager cli commands", () => { }); const accountsModule = await import("../lib/accounts.js"); const extractAccountIdMock = vi.mocked(accountsModule.extractAccountId); - const extractAccountEmailMock = vi.mocked(accountsModule.extractAccountEmail); - extractAccountIdMock.mockImplementation( - (accessToken?: string) => { - if (accessToken === "access-alpha-stale") return "shared-workspace"; - if (accessToken === "access-alpha-refreshed") return "shared-workspace"; - if (accessToken === "access-beta") return "shared-workspace"; - return "acc_test"; - }, - ); - extractAccountEmailMock.mockImplementation( - (accessToken?: string) => { - if (accessToken === "access-alpha-refreshed") return "owner@example.com"; - return undefined; - }, + const extractAccountEmailMock = vi.mocked( + accountsModule.extractAccountEmail, ); + extractAccountIdMock.mockImplementation((accessToken?: string) => { + if (accessToken === "access-alpha-stale") return "shared-workspace"; + if (accessToken === "access-alpha-refreshed") return "shared-workspace"; + if (accessToken === "access-beta") return "shared-workspace"; + return "acc_test"; + }); + extractAccountEmailMock.mockImplementation((accessToken?: string) => { + if (accessToken === "access-alpha-refreshed") return "owner@example.com"; + return undefined; + }); fetchCodexQuotaSnapshotMock .mockResolvedValueOnce({ status: 200, @@ -1690,21 +1766,19 @@ describe("codex manager cli commands", () => { }); const accountsModule = await import("../lib/accounts.js"); const extractAccountIdMock = vi.mocked(accountsModule.extractAccountId); - const extractAccountEmailMock = vi.mocked(accountsModule.extractAccountEmail); - extractAccountIdMock.mockImplementation( - (accessToken?: string) => { - if (accessToken === "access-alpha-stale") return "shared-workspace"; - if (accessToken === "access-alpha-refreshed") return "shared-workspace"; - if (accessToken === "access-beta") return "shared-workspace"; - return "acc_test"; - }, - ); - extractAccountEmailMock.mockImplementation( - (accessToken?: string) => { - if (accessToken === "access-alpha-refreshed") return "owner@example.com"; - return undefined; - }, + const extractAccountEmailMock = vi.mocked( + accountsModule.extractAccountEmail, ); + extractAccountIdMock.mockImplementation((accessToken?: string) => { + if (accessToken === "access-alpha-stale") return "shared-workspace"; + if (accessToken === "access-alpha-refreshed") return "shared-workspace"; + if (accessToken === "access-beta") return "shared-workspace"; + return "acc_test"; + }); + extractAccountEmailMock.mockImplementation((accessToken?: string) => { + if (accessToken === "access-alpha-refreshed") return "owner@example.com"; + return undefined; + }); fetchCodexQuotaSnapshotMock .mockResolvedValueOnce({ status: 200, @@ -1850,10 +1924,12 @@ describe("codex manager cli commands", () => { ); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - await expect(runCodexMultiAuthCli(["auth", "check"])).rejects.toMatchObject({ - code: "EBUSY", - message: "save failed", - }); + await expect(runCodexMultiAuthCli(["auth", "check"])).rejects.toMatchObject( + { + code: "EBUSY", + message: "save failed", + }, + ); expect(originalQuotaCache).toEqual({ byAccountId: {}, byEmail: {}, @@ -1959,7 +2035,9 @@ describe("codex manager cli commands", () => { }, ], }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -2080,7 +2158,12 @@ describe("codex manager cli commands", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "best", "--live", "--json"]); + const exitCode = await runCodexMultiAuthCli([ + "auth", + "best", + "--live", + "--json", + ]); expect(exitCode).toBe(0); expect(setCodexCliActiveSelectionMock).toHaveBeenCalledTimes(1); @@ -2128,7 +2211,9 @@ describe("codex manager cli commands", () => { }, ], }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -2282,9 +2367,11 @@ describe("codex manager cli commands", () => { ); expect(saveAccountsMock).not.toHaveBeenCalled(); expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); - expect(logSpy.mock.calls.some((call) => - String(call[0]).includes("Already on best account 1"), - )).toBe(true); + expect( + logSpy.mock.calls.some((call) => + String(call[0]).includes("Already on best account 1"), + ), + ).toBe(true); }); it("syncs refreshed current best account during live best check", async () => { @@ -2304,7 +2391,9 @@ describe("codex manager cli commands", () => { }, ], }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -2350,9 +2439,11 @@ describe("codex manager cli commands", () => { idToken: "id-best-next", }), ); - expect(logSpy.mock.calls.some((call) => - String(call[0]).includes("Already on best account 1"), - )).toBe(true); + expect( + logSpy.mock.calls.some((call) => + String(call[0]).includes("Already on best account 1"), + ), + ).toBe(true); }); it("reports synced=false in already-best json output when live sync fails", async () => { @@ -2372,7 +2463,9 @@ describe("codex manager cli commands", () => { }, ], }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -2402,7 +2495,12 @@ describe("codex manager cli commands", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "best", "--live", "--json"]); + const exitCode = await runCodexMultiAuthCli([ + "auth", + "best", + "--live", + "--json", + ]); expect(exitCode).toBe(0); expect(saveAccountsMock).toHaveBeenCalledTimes(1); @@ -2506,7 +2604,9 @@ describe("codex manager cli commands", () => { }, ], }); - fetchCodexQuotaSnapshotMock.mockRejectedValueOnce(new Error("network timeout")); + fetchCodexQuotaSnapshotMock.mockRejectedValueOnce( + new Error("network timeout"), + ); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); @@ -2515,15 +2615,21 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(saveAccountsMock).not.toHaveBeenCalled(); expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); - expect(logSpy.mock.calls.some((call) => - String(call[0]).includes("Already on best account 1"), - )).toBe(true); - expect(logSpy.mock.calls.some((call) => - String(call[0]).includes("Live check notes (1)"), - )).toBe(true); - expect(logSpy.mock.calls.some((call) => - String(call[0]).includes("network timeout"), - )).toBe(true); + expect( + logSpy.mock.calls.some((call) => + String(call[0]).includes("Already on best account 1"), + ), + ).toBe(true); + expect( + logSpy.mock.calls.some((call) => + String(call[0]).includes("Live check notes (1)"), + ), + ).toBe(true); + expect( + logSpy.mock.calls.some((call) => + String(call[0]).includes("network timeout"), + ), + ).toBe(true); }); it("reuses the queued refresh result across concurrent live best runs", async () => { @@ -2543,7 +2649,9 @@ describe("codex manager cli commands", () => { }, ], }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -2591,7 +2699,12 @@ describe("codex manager cli commands", () => { const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const firstRun = runCodexMultiAuthCli(["auth", "best", "--live", "--json"]); - const secondRun = runCodexMultiAuthCli(["auth", "best", "--live", "--json"]); + const secondRun = runCodexMultiAuthCli([ + "auth", + "best", + "--live", + "--json", + ]); refreshDeferred.resolve({ type: "success", @@ -2601,7 +2714,10 @@ describe("codex manager cli commands", () => { idToken: "id-best-next", }); - const [firstExitCode, secondExitCode] = await Promise.all([firstRun, secondRun]); + const [firstExitCode, secondExitCode] = await Promise.all([ + firstRun, + secondRun, + ]); expect(firstExitCode).toBe(0); expect(secondExitCode).toBe(0); @@ -2612,14 +2728,16 @@ describe("codex manager cli commands", () => { expect(storageState.accounts[0]?.refreshToken).toBe("refresh-best-next"); expect(setCodexCliActiveSelectionMock).toHaveBeenCalledTimes(2); for (const call of setCodexCliActiveSelectionMock.mock.calls) { - expect(call[0]).toEqual(expect.objectContaining({ - accountId: "acc_test", - email: "best@example.com", - accessToken: "access-best-next", - refreshToken: "refresh-best-next", - expiresAt: now + 3_600_000, - idToken: "id-best-next", - })); + expect(call[0]).toEqual( + expect.objectContaining({ + accountId: "acc_test", + email: "best@example.com", + accessToken: "access-best-next", + refreshToken: "refresh-best-next", + expiresAt: now + 3_600_000, + idToken: "id-best-next", + }), + ); } expect(logSpy.mock.calls).toHaveLength(2); for (const call of logSpy.mock.calls) { @@ -3022,7 +3140,9 @@ describe("codex manager cli commands", () => { activeIndexByFamily: { codex: 0 }, accounts: [] as Array>, }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -3064,12 +3184,16 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(openBrowserUrlMock).not.toHaveBeenCalled(); - expect(vi.mocked(serverModule.startLocalOAuthServer)).not.toHaveBeenCalled(); + expect( + vi.mocked(serverModule.startLocalOAuthServer), + ).not.toHaveBeenCalled(); expect(waitForCodeMock).not.toHaveBeenCalled(); - expect(renderedLogs.some((entry) => entry.includes("Manual mode active"))).toBe( - true, - ); - expect(renderedLogs.some((entry) => entry.includes("No callback received"))).toBe(false); + expect( + renderedLogs.some((entry) => entry.includes("Manual mode active")), + ).toBe(true); + expect( + renderedLogs.some((entry) => entry.includes("No callback received")), + ).toBe(false); expect(storageState.accounts).toHaveLength(1); }); @@ -3138,7 +3262,9 @@ describe("codex manager cli commands", () => { mtimeMs: now - 60_000, }, ]); - restoreAccountsFromBackupMock.mockResolvedValue(structuredClone(restoredStorage)); + restoreAccountsFromBackupMock.mockResolvedValue( + structuredClone(restoredStorage), + ); setCodexCliActiveSelectionMock.mockResolvedValueOnce(true); selectMock .mockResolvedValueOnce("restore-backup") @@ -3163,9 +3289,10 @@ describe("codex manager cli commands", () => { expect(signInItems.map((item) => item.label)).toContain( "Recover saved accounts", ); - expect(signInItems.find((item) => item.label === "Recover saved accounts")?.kind).toBe( - "heading", - ); + expect( + signInItems.find((item) => item.label === "Recover saved accounts") + ?.kind, + ).toBe("heading"); expect( signInItems.find((item) => item.label === "Restore Saved Backup")?.hint, ).toBe("last-good.json | 2 accounts | saved Localized Saved Time"); @@ -3180,7 +3307,9 @@ describe("codex manager cli commands", () => { "/mock/backups/last-good.json", { persist: false }, ); - expect(confirmMock).toHaveBeenCalledWith("Load last-good.json (2 accounts)?"); + expect(confirmMock).toHaveBeenCalledWith( + "Load last-good.json (2 accounts)?", + ); expect(saveAccountsMock).toHaveBeenCalledTimes(1); expect(saveAccountsMock.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ @@ -3280,7 +3409,9 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(restoreAccountsFromBackupMock).not.toHaveBeenCalled(); - expect(confirmMock).not.toHaveBeenCalledWith("Load manual-choice.json (1 account)?"); + expect(confirmMock).not.toHaveBeenCalledWith( + "Load manual-choice.json (1 account)?", + ); expect(saveAccountsMock).not.toHaveBeenCalled(); expect(promptLoginModeMock).not.toHaveBeenCalled(); }); @@ -3356,7 +3487,9 @@ describe("codex manager cli commands", () => { expect.any(String), ]); expect(restoreAccountsFromBackupMock).not.toHaveBeenCalled(); - expect(confirmMock).toHaveBeenCalledWith("Load replacement.json (2 accounts)?"); + expect(confirmMock).toHaveBeenCalledWith( + "Load replacement.json (2 accounts)?", + ); }); it("does not offer backup restore on onboarding when accounts already exist", async () => { @@ -3494,7 +3627,9 @@ describe("codex manager cli commands", () => { mtimeMs: now, }, ]); - restoreAccountsFromBackupMock.mockResolvedValue(structuredClone(restoredStorage)); + restoreAccountsFromBackupMock.mockResolvedValue( + structuredClone(restoredStorage), + ); setCodexCliActiveSelectionMock.mockResolvedValueOnce(false); selectMock .mockResolvedValueOnce("restore-backup") @@ -3556,7 +3691,9 @@ describe("codex manager cli commands", () => { expect(saveAccountsMock).not.toHaveBeenCalled(); expect(promptLoginModeMock).not.toHaveBeenCalled(); expect(selectMock).toHaveBeenCalledTimes(3); - expect(errorSpy).toHaveBeenCalledWith("Backup restore failed: File is busy"); + expect(errorSpy).toHaveBeenCalledWith( + "Backup restore failed: File is busy", + ); }); it("prints the storage hint only once when restore fails with StorageError", async () => { @@ -3627,8 +3764,12 @@ describe("codex manager cli commands", () => { mtimeMs: now, }, ]); - restoreAccountsFromBackupMock.mockResolvedValue(structuredClone(restoredStorage)); - saveAccountsMock.mockRejectedValueOnce(new Error("save selected account failed")); + restoreAccountsFromBackupMock.mockResolvedValue( + structuredClone(restoredStorage), + ); + saveAccountsMock.mockRejectedValueOnce( + new Error("save selected account failed"), + ); selectMock .mockResolvedValueOnce("restore-backup") .mockResolvedValueOnce("latest") @@ -3688,7 +3829,9 @@ describe("codex manager cli commands", () => { mtimeMs: now, }, ]); - restoreAccountsFromBackupMock.mockResolvedValue(structuredClone(restoredStorage)); + restoreAccountsFromBackupMock.mockResolvedValue( + structuredClone(restoredStorage), + ); selectMock .mockResolvedValueOnce("restore-backup") .mockResolvedValueOnce("latest"); @@ -3763,7 +3906,9 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(confirmMock).toHaveBeenCalledWith("Load manual-choice.json (1 account)?"); + expect(confirmMock).toHaveBeenCalledWith( + "Load manual-choice.json (1 account)?", + ); expect(restoreAccountsFromBackupMock).not.toHaveBeenCalled(); expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); }); @@ -3830,17 +3975,21 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(selectMock).toHaveBeenCalled(); expect(openBrowserUrlMock).not.toHaveBeenCalled(); - expect(vi.mocked(serverModule.startLocalOAuthServer)).not.toHaveBeenCalled(); + expect( + vi.mocked(serverModule.startLocalOAuthServer), + ).not.toHaveBeenCalled(); expect(waitForCodeMock).not.toHaveBeenCalled(); const signInItems = selectMock.mock.calls[0]?.[0] as Array<{ label: string; value?: string; }>; expect(signInItems.some((item) => item.value === "manual")).toBe(true); - expect(renderedLogs.some((entry) => entry.includes("Manual mode active"))).toBe( - true, - ); - expect(renderedLogs.some((entry) => entry.includes("No callback received"))).toBe(false); + expect( + renderedLogs.some((entry) => entry.includes("Manual mode active")), + ).toBe(true); + expect( + renderedLogs.some((entry) => entry.includes("No callback received")), + ).toBe(false); expect(logSpy).toHaveBeenCalledWith("Refreshed account 1."); }); @@ -3853,7 +4002,9 @@ describe("codex manager cli commands", () => { activeIndexByFamily: { codex: 0 }, accounts: [] as Array>, }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -3881,9 +4032,13 @@ describe("codex manager cli commands", () => { const browserModule = await import("../lib/auth/browser.js"); const openBrowserUrlMock = vi.mocked(browserModule.openBrowserUrl); - vi.mocked(browserModule.isBrowserLaunchSuppressed).mockReturnValueOnce(true); + vi.mocked(browserModule.isBrowserLaunchSuppressed).mockReturnValueOnce( + true, + ); const serverModule = await import("../lib/auth/server.js"); - const startLocalOAuthServerMock = vi.mocked(serverModule.startLocalOAuthServer); + const startLocalOAuthServerMock = vi.mocked( + serverModule.startLocalOAuthServer, + ); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); @@ -3904,7 +4059,9 @@ describe("codex manager cli commands", () => { activeIndexByFamily: { codex: 0 }, accounts: [] as Array>, }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -3938,7 +4095,9 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(promptQuestionMock).toHaveBeenCalledWith(""); expect(openBrowserUrlMock).not.toHaveBeenCalled(); - expect(vi.mocked(serverModule.startLocalOAuthServer)).not.toHaveBeenCalled(); + expect( + vi.mocked(serverModule.startLocalOAuthServer), + ).not.toHaveBeenCalled(); expect(storageState.accounts).toHaveLength(1); }); @@ -3950,7 +4109,9 @@ describe("codex manager cli commands", () => { activeIndexByFamily: { codex: 0 }, accounts: [] as Array>, }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -3965,7 +4126,9 @@ describe("codex manager cli commands", () => { state: "oauth-state", url: "https://auth.openai.com/mock", }); - const exchangeAuthorizationCodeMock = vi.mocked(authModule.exchangeAuthorizationCode); + const exchangeAuthorizationCodeMock = vi.mocked( + authModule.exchangeAuthorizationCode, + ); const browserModule = await import("../lib/auth/browser.js"); const openBrowserUrlMock = vi.mocked(browserModule.openBrowserUrl); @@ -3977,7 +4140,9 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(promptQuestionMock).toHaveBeenCalledWith(""); expect(openBrowserUrlMock).not.toHaveBeenCalled(); - expect(vi.mocked(serverModule.startLocalOAuthServer)).not.toHaveBeenCalled(); + expect( + vi.mocked(serverModule.startLocalOAuthServer), + ).not.toHaveBeenCalled(); expect(exchangeAuthorizationCodeMock).not.toHaveBeenCalled(); expect(storageState.accounts).toHaveLength(0); }); @@ -3991,7 +4156,9 @@ describe("codex manager cli commands", () => { activeIndexByFamily: { codex: 0 }, accounts: [] as Array>, }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -4025,7 +4192,9 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(promptQuestionMock).toHaveBeenCalledWith(""); expect(openBrowserUrlMock).not.toHaveBeenCalled(); - expect(vi.mocked(serverModule.startLocalOAuthServer)).not.toHaveBeenCalled(); + expect( + vi.mocked(serverModule.startLocalOAuthServer), + ).not.toHaveBeenCalled(); expect(storageState.accounts).toHaveLength(1); }); @@ -4037,7 +4206,9 @@ describe("codex manager cli commands", () => { activeIndexByFamily: { codex: 0 }, accounts: [] as Array>, }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -4050,7 +4221,9 @@ describe("codex manager cli commands", () => { state: "oauth-state", url: "https://auth.openai.com/mock", }); - const exchangeAuthorizationCodeMock = vi.mocked(authModule.exchangeAuthorizationCode); + const exchangeAuthorizationCodeMock = vi.mocked( + authModule.exchangeAuthorizationCode, + ); const browserModule = await import("../lib/auth/browser.js"); const openBrowserUrlMock = vi.mocked(browserModule.openBrowserUrl); @@ -4062,7 +4235,9 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(promptQuestionMock).toHaveBeenCalledWith(""); expect(openBrowserUrlMock).not.toHaveBeenCalled(); - expect(vi.mocked(serverModule.startLocalOAuthServer)).not.toHaveBeenCalled(); + expect( + vi.mocked(serverModule.startLocalOAuthServer), + ).not.toHaveBeenCalled(); expect(exchangeAuthorizationCodeMock).not.toHaveBeenCalled(); expect(storageState.accounts).toHaveLength(0); }); @@ -4108,7 +4283,9 @@ describe("codex manager cli commands", () => { mtimeMs: now - 60_000, }, ]); - restoreAccountsFromBackupMock.mockResolvedValue(structuredClone(restoredStorage)); + restoreAccountsFromBackupMock.mockResolvedValue( + structuredClone(restoredStorage), + ); setCodexCliActiveSelectionMock.mockResolvedValueOnce(true); selectMock .mockResolvedValueOnce("restore-backup") @@ -4130,7 +4307,9 @@ describe("codex manager cli commands", () => { "/mock/backups/manual-choice.json", { persist: false }, ); - expect(confirmMock).toHaveBeenCalledWith("Load manual-choice.json (1 account)?"); + expect(confirmMock).toHaveBeenCalledWith( + "Load manual-choice.json (1 account)?", + ); expect(saveAccountsMock).toHaveBeenCalledTimes(1); expect(setCodexCliActiveSelectionMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -4186,7 +4365,9 @@ describe("codex manager cli commands", () => { mtimeMs: now, }, ]); - restoreAccountsFromBackupMock.mockResolvedValue(structuredClone(restoredStorage)); + restoreAccountsFromBackupMock.mockResolvedValue( + structuredClone(restoredStorage), + ); setCodexCliActiveSelectionMock.mockResolvedValueOnce(true); selectMock .mockResolvedValueOnce("restore-backup") @@ -4242,9 +4423,7 @@ describe("codex manager cli commands", () => { mtimeMs: now, }, ]); - selectMock - .mockResolvedValueOnce("browser") - .mockResolvedValueOnce("cancel"); + selectMock.mockResolvedValueOnce("browser").mockResolvedValueOnce("cancel"); promptAddAnotherAccountMock.mockResolvedValueOnce(true); promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); @@ -4292,12 +4471,12 @@ describe("codex manager cli commands", () => { label: string; value?: string; }>; - expect(firstSignInItems.some((item) => item.value === "restore-backup")).toBe( - true, - ); - expect(secondSignInItems.some((item) => item.value === "restore-backup")).toBe( - false, - ); + expect( + firstSignInItems.some((item) => item.value === "restore-backup"), + ).toBe(true); + expect( + secondSignInItems.some((item) => item.value === "restore-backup"), + ).toBe(false); expect(promptLoginModeMock).toHaveBeenCalledTimes(1); }); it("preserves distinct same-email workspaces when oauth login reuses a refresh token", async () => { @@ -4908,13 +5087,11 @@ describe("codex manager cli commands", () => { }); const accountsModule = await import("../lib/accounts.js"); const extractAccountIdMock = vi.mocked(accountsModule.extractAccountId); - extractAccountIdMock.mockImplementation( - (accessToken?: string) => { - if (accessToken === "access-alpha") return "workspace-alpha"; - if (accessToken === "access-beta") return "workspace-beta"; - return "acc_test"; - }, - ); + extractAccountIdMock.mockImplementation((accessToken?: string) => { + if (accessToken === "access-alpha") return "workspace-alpha"; + if (accessToken === "access-beta") return "workspace-beta"; + return "acc_test"; + }); promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); @@ -4993,13 +5170,11 @@ describe("codex manager cli commands", () => { }); const accountsModule = await import("../lib/accounts.js"); const extractAccountIdMock = vi.mocked(accountsModule.extractAccountId); - extractAccountIdMock.mockImplementation( - (accessToken?: string) => { - if (accessToken === "access-alpha") return "workspace-alpha"; - if (accessToken === "access-beta") return "workspace-beta"; - return "acc_test"; - }, - ); + extractAccountIdMock.mockImplementation((accessToken?: string) => { + if (accessToken === "access-alpha") return "workspace-alpha"; + if (accessToken === "access-beta") return "workspace-beta"; + return "acc_test"; + }); fetchCodexQuotaSnapshotMock .mockResolvedValueOnce({ status: 200, @@ -5745,9 +5920,7 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(readSettingsHubPanelContract()).toEqual( - SETTINGS_HUB_MENU_ORDER, - ); + expect(readSettingsHubPanelContract()).toEqual(SETTINGS_HUB_MENU_ORDER); expect(selectSequence.remaining()).toBe(0); expect(saveDashboardDisplaySettingsMock).toHaveBeenCalled(); expect(savePluginConfigMock).toHaveBeenCalledTimes(1); @@ -5774,7 +5947,17 @@ describe("codex manager cli commands", () => { it("runs experimental oc sync with mandatory preview before apply", async () => { const now = Date.now(); setupInteractiveSettingsLogin(createSettingsStorage(now)); - detectOcChatgptMultiAuthTargetMock.mockReturnValue({ kind: "target", descriptor: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" } }); + detectOcChatgptMultiAuthTargetMock.mockReturnValue({ + kind: "target", + descriptor: { + scope: "global", + root: "C:/target", + accountPath: "C:/target/openai-codex-accounts.json", + backupRoot: "C:/target/backups", + source: "default-global", + resolution: "accounts", + }, + }); planOcChatgptSyncMock.mockResolvedValue({ kind: "ready", target: { @@ -5830,7 +6013,11 @@ describe("codex manager cli commands", () => { expect(applyOcChatgptSyncMock).toHaveBeenCalledOnce(); expect(selectMock).toHaveBeenCalledWith( expect.arrayContaining([ - expect.objectContaining({ label: expect.stringContaining("Active selection: preserve-destination") }), + expect.objectContaining({ + label: expect.stringContaining( + "Active selection: preserve-destination", + ), + }), ]), expect.any(Object), ); @@ -5908,10 +6095,24 @@ describe("codex manager cli commands", () => { it("shows guidance when experimental oc sync target is ambiguous or unreadable", async () => { const now = Date.now(); setupInteractiveSettingsLogin(createSettingsStorage(now)); - detectOcChatgptMultiAuthTargetMock.mockReturnValue({ kind: "target", descriptor: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" } }); + detectOcChatgptMultiAuthTargetMock.mockReturnValue({ + kind: "target", + descriptor: { + scope: "global", + root: "C:/target", + accountPath: "C:/target/openai-codex-accounts.json", + backupRoot: "C:/target/backups", + source: "default-global", + resolution: "accounts", + }, + }); planOcChatgptSyncMock.mockResolvedValue({ kind: "blocked-ambiguous", - detection: { kind: "ambiguous", reason: "multiple targets", candidates: [] }, + detection: { + kind: "ambiguous", + reason: "multiple targets", + candidates: [], + }, }); const selectSequence = queueSettingsSelectSequence([ { type: "experimental" }, @@ -5930,12 +6131,14 @@ describe("codex manager cli commands", () => { expect(applyOcChatgptSyncMock).not.toHaveBeenCalled(); }); - it("exports named pool backup from experimental settings", async () => { const now = Date.now(); setupInteractiveSettingsLogin(createSettingsStorage(now)); promptQuestionMock.mockResolvedValueOnce("backup-2026-03-10"); - runNamedBackupExportMock.mockResolvedValueOnce({ kind: "exported", path: "/mock/backups/backup-2026-03-10.json" }); + runNamedBackupExportMock.mockResolvedValueOnce({ + kind: "exported", + path: "/mock/backups/backup-2026-03-10.json", + }); const selectSequence = queueSettingsSelectSequence([ { type: "experimental" }, { type: "backup" }, @@ -5950,7 +6153,9 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(selectSequence.remaining()).toBe(0); expect(promptQuestionMock).toHaveBeenCalledOnce(); - expect(runNamedBackupExportMock).toHaveBeenCalledWith({ name: "backup-2026-03-10" }); + expect(runNamedBackupExportMock).toHaveBeenCalledWith({ + name: "backup-2026-03-10", + }); }); it("supports backup hotkeys from experimental menu through result status", async () => { @@ -5984,7 +6189,10 @@ describe("codex manager cli commands", () => { const now = Date.now(); setupInteractiveSettingsLogin(createSettingsStorage(now)); promptQuestionMock.mockResolvedValueOnce("../bad-name"); - runNamedBackupExportMock.mockResolvedValueOnce({ kind: "collision", path: "/mock/backups/bad-name.json" }); + runNamedBackupExportMock.mockResolvedValueOnce({ + kind: "collision", + path: "/mock/backups/bad-name.json", + }); const selectSequence = queueSettingsSelectSequence([ { type: "experimental" }, { type: "backup" }, @@ -5999,18 +6207,49 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(selectSequence.remaining()).toBe(0); expect(promptQuestionMock).toHaveBeenCalledOnce(); - expect(runNamedBackupExportMock).toHaveBeenCalledWith({ name: "../bad-name" }); + expect(runNamedBackupExportMock).toHaveBeenCalledWith({ + name: "../bad-name", + }); }); it("backs out of experimental sync preview without applying", async () => { const now = Date.now(); setupInteractiveSettingsLogin(createSettingsStorage(now)); - detectOcChatgptMultiAuthTargetMock.mockReturnValue({ kind: "target", descriptor: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" } }); - normalizeAccountStorageMock.mockReturnValue({ version: 3, accounts: [], activeIndex: 0 }); + detectOcChatgptMultiAuthTargetMock.mockReturnValue({ + kind: "target", + descriptor: { + scope: "global", + root: "C:/target", + accountPath: "C:/target/openai-codex-accounts.json", + backupRoot: "C:/target/backups", + source: "default-global", + resolution: "accounts", + }, + }); + normalizeAccountStorageMock.mockReturnValue({ + version: 3, + accounts: [], + activeIndex: 0, + }); planOcChatgptSyncMock.mockResolvedValue({ kind: "ready", - target: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" }, - preview: { payload: { version: 3, accounts: [], activeIndex: 0 }, merged: { version: 3, accounts: [], activeIndex: 0 }, toAdd: [], toUpdate: [], toSkip: [], unchangedDestinationOnly: [], activeSelectionBehavior: "preserve-destination" }, + target: { + scope: "global", + root: "C:/target", + accountPath: "C:/target/openai-codex-accounts.json", + backupRoot: "C:/target/backups", + source: "default-global", + resolution: "accounts", + }, + preview: { + payload: { version: 3, accounts: [], activeIndex: 0 }, + merged: { version: 3, accounts: [], activeIndex: 0 }, + toAdd: [], + toUpdate: [], + toSkip: [], + unchangedDestinationOnly: [], + activeSelectionBehavior: "preserve-destination", + }, payload: { version: 3, accounts: [], activeIndex: 0 }, destination: { version: 3, accounts: [], activeIndex: 0 }, }); @@ -6078,7 +6317,11 @@ describe("codex manager cli commands", () => { }); planOcChatgptSyncMock.mockResolvedValue({ kind: "blocked-ambiguous", - detection: { kind: "ambiguous", reason: "multiple targets", candidates: [] }, + detection: { + kind: "ambiguous", + reason: "multiple targets", + candidates: [], + }, }); const selectSequence = queueSettingsSelectSequence([ { type: "experimental" }, @@ -6237,9 +6480,7 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(readSettingsHubPanelContract()).toEqual( - SETTINGS_HUB_MENU_ORDER, - ); + expect(readSettingsHubPanelContract()).toEqual(SETTINGS_HUB_MENU_ORDER); expect(selectSequence.remaining()).toBe(0); expect(saveDashboardDisplaySettingsMock).toHaveBeenCalledTimes(4); expect(saveDashboardDisplaySettingsMock.mock.calls[0]?.[0]).toEqual( @@ -6276,7 +6517,6 @@ describe("codex manager cli commands", () => { ); }); - it("moves guardian controls into experimental settings", async () => { const now = Date.now(); setupInteractiveSettingsLogin(createSettingsStorage(now)); @@ -6299,7 +6539,8 @@ describe("codex manager cli commands", () => { expect(savePluginConfigMock).toHaveBeenCalledWith( expect.objectContaining({ proactiveRefreshGuardian: !(defaults.proactiveRefreshGuardian ?? false), - proactiveRefreshIntervalMs: (defaults.proactiveRefreshIntervalMs ?? 60000) + 60000, + proactiveRefreshIntervalMs: + (defaults.proactiveRefreshIntervalMs ?? 60000) + 60000, }), ); }); @@ -6359,8 +6600,7 @@ describe("codex manager cli commands", () => { preemptiveQuotaRemainingPercent5h: (defaults.preemptiveQuotaRemainingPercent5h ?? 0) + 1, storageBackupEnabled: !(defaults.storageBackupEnabled ?? false), - tokenRefreshSkewMs: - (defaults.tokenRefreshSkewMs ?? 60_000) + 10_000, + tokenRefreshSkewMs: (defaults.tokenRefreshSkewMs ?? 60_000) + 10_000, parallelProbing: !(defaults.parallelProbing ?? false), fetchTimeoutMs: (defaults.fetchTimeoutMs ?? 60_000) + 5_000, }), @@ -7121,7 +7361,8 @@ describe("codex manager cli commands", () => { ); vi.mocked(accountsModule.extractAccountEmail).mockImplementation( (accessToken?: string) => { - if (accessToken === "access-alpha-refreshed") return "owner@example.com"; + if (accessToken === "access-alpha-refreshed") + return "owner@example.com"; return undefined; }, ); @@ -7243,21 +7484,19 @@ describe("codex manager cli commands", () => { }); const accountsModule = await import("../lib/accounts.js"); const extractAccountIdMock = vi.mocked(accountsModule.extractAccountId); - const extractAccountEmailMock = vi.mocked(accountsModule.extractAccountEmail); - extractAccountIdMock.mockImplementation( - (accessToken?: string) => { - if (accessToken === "access-alpha-stale") return "shared-workspace"; - if (accessToken === "access-alpha-refreshed") return "shared-workspace"; - if (accessToken === "access-beta") return "shared-workspace"; - return "acc_test"; - }, - ); - extractAccountEmailMock.mockImplementation( - (accessToken?: string) => { - if (accessToken === "access-alpha-refreshed") return "owner@example.com"; - return undefined; - }, + const extractAccountEmailMock = vi.mocked( + accountsModule.extractAccountEmail, ); + extractAccountIdMock.mockImplementation((accessToken?: string) => { + if (accessToken === "access-alpha-stale") return "shared-workspace"; + if (accessToken === "access-alpha-refreshed") return "shared-workspace"; + if (accessToken === "access-beta") return "shared-workspace"; + return "acc_test"; + }); + extractAccountEmailMock.mockImplementation((accessToken?: string) => { + if (accessToken === "access-alpha-refreshed") return "owner@example.com"; + return undefined; + }); fetchCodexQuotaSnapshotMock .mockResolvedValueOnce({ status: 200, @@ -7419,7 +7658,8 @@ describe("codex manager cli commands", () => { ); vi.mocked(accountsModule.extractAccountEmail).mockImplementation( (accessToken?: string) => { - if (accessToken === "access-alpha-refreshed") return "owner@example.com"; + if (accessToken === "access-alpha-refreshed") + return "owner@example.com"; return undefined; }, ); From 54a5068e105d4dc5ccf994af5fcfe87f54cbdd5e Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 04:32:54 +0800 Subject: [PATCH 024/376] refactor: route list and status through command module --- lib/codex-manager.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 4f7b6825..af128432 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -1983,15 +1983,6 @@ async function syncSelectionToCodex( }); } -async function showAccountStatus(): Promise { - await runStatusCommand({ - setStoragePath, - loadAccounts, - resolveActiveIndex, - formatRateLimitEntry, - }); -} - interface HealthCheckOptions { forceRefresh?: boolean; liveProbe?: boolean; @@ -5292,8 +5283,12 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { return runAuthLogin(rest); } if (command === "list" || command === "status") { - await showAccountStatus(); - return 0; + return runStatusCommand({ + setStoragePath, + loadAccounts, + resolveActiveIndex, + formatRateLimitEntry, + }); } if (command === "switch") { return runSwitch(rest); From f29cf31c5ec1199f83f256765b809849bef58192 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 04:37:08 +0800 Subject: [PATCH 025/376] refactor: route features through command module --- lib/codex-manager.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index af128432..26d0ccbe 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -434,10 +434,6 @@ const IMPLEMENTED_FEATURES: ImplementedFeature[] = [ { id: 41, name: "Auto-switch to best account command" }, ]; -function runFeaturesReport(): number { - return runFeaturesCommand({ implementedFeatures: IMPLEMENTED_FEATURES }); -} - function resolveActiveIndex( storage: AccountStorageV3, family: ModelFamily = "codex", @@ -5297,7 +5293,7 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { return runCheckCommand({ runHealthCheck }); } if (command === "features") { - return runFeaturesReport(); + return runFeaturesCommand({ implementedFeatures: IMPLEMENTED_FEATURES }); } if (command === "verify-flagged") { return runVerifyFlagged(rest); From 9bfbcc601a06a2ef6b6c02169c7ddf9d826b5d93 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 04:39:09 +0800 Subject: [PATCH 026/376] refactor: route switch through command module --- lib/codex-manager.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 26d0ccbe..07fccee6 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -4382,7 +4382,11 @@ async function handleManageAction( ): Promise { if (typeof menuResult.switchAccountIndex === "number") { const index = menuResult.switchAccountIndex; - await runSwitch([String(index + 1)]); + await runSwitchCommand([String(index + 1)], { + setStoragePath, + loadAccounts, + persistAndSyncSelectedAccount, + }); return; } @@ -4796,14 +4800,6 @@ async function runAuthLogin(args: string[]): Promise { } } -async function runSwitch(args: string[]): Promise { - return runSwitchCommand(args, { - setStoragePath, - loadAccounts, - persistAndSyncSelectedAccount, - }); -} - async function persistAndSyncSelectedAccount({ storage, targetIndex, @@ -5287,7 +5283,11 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { }); } if (command === "switch") { - return runSwitch(rest); + return runSwitchCommand(rest, { + setStoragePath, + loadAccounts, + persistAndSyncSelectedAccount, + }); } if (command === "check") { return runCheckCommand({ runHealthCheck }); From 810af197c99fb01b2fa8c25469a2bb41bc08993e Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 04:44:38 +0800 Subject: [PATCH 027/376] fix doctor auto-fix transaction flow --- lib/codex-manager/repair-commands.ts | 95 ++++++++++++++++++++++++---- test/repair-commands.test.ts | 41 ++++++++++++ 2 files changed, 122 insertions(+), 14 deletions(-) diff --git a/lib/codex-manager/repair-commands.ts b/lib/codex-manager/repair-commands.ts index 8eafd7c2..8f125536 100644 --- a/lib/codex-manager/repair-commands.ts +++ b/lib/codex-manager/repair-commands.ts @@ -589,6 +589,15 @@ interface DoctorFixAction { message: string; } +type DoctorRefreshMutation = { + match: AccountMetadataV3; + accessToken: string; + refreshToken: string; + expiresAt: number; + email?: string; + accountId?: string; +}; + function maskDoctorEmail(value: string | undefined): string | undefined { if (!value) return undefined; const email = value.trim(); @@ -1655,10 +1664,11 @@ export async function runDoctor( }); const storage = await loadAccounts(); - const originalDoctorAccounts = storage?.accounts.map((account) => structuredClone(account)) ?? []; let fixChanged = false; let storageFixChanged = false; - let fixActions: DoctorFixAction[] = []; + let structuralFixActions: DoctorFixAction[] = []; + const supplementalFixActions: DoctorFixAction[] = []; + let doctorRefreshMutation: DoctorRefreshMutation | null = null; let pendingCodexActiveSync: { accountId: string | undefined; email: string | undefined; @@ -1670,7 +1680,7 @@ export async function runDoctor( if (options.fix && storage && storage.accounts.length > 0) { const fixed = applyDoctorFixes(storage, deps); storageFixChanged = fixed.changed; - fixActions = fixed.actions; + structuralFixActions = fixed.actions; } if (!storage || storage.accounts.length === 0) { addCheck({ @@ -1827,6 +1837,7 @@ export async function runDoctor( }); if (options.fix && activeAccount) { + const activeAccountMatch = structuredClone(activeAccount); let syncAccessToken = activeAccount.accessToken; let syncRefreshToken = activeAccount.refreshToken; let syncExpiresAt = activeAccount.expiresAt; @@ -1835,7 +1846,7 @@ export async function runDoctor( if (!canSyncActiveAccount) { if (options.dryRun) { - fixActions.push({ + supplementalFixActions.push({ key: "doctor-refresh", message: `Prepared active-account token refresh for account ${activeIndex + 1} (dry-run)`, }); @@ -1851,6 +1862,14 @@ export async function runDoctor( activeAccount.expiresAt = refreshResult.expires; if (refreshedEmail) activeAccount.email = refreshedEmail; deps.applyTokenAccountIdentity(activeAccount, refreshedAccountId); + doctorRefreshMutation = { + match: activeAccountMatch, + accessToken: refreshResult.access, + refreshToken: refreshResult.refresh, + expiresAt: refreshResult.expires, + ...(refreshedEmail ? { email: refreshedEmail } : {}), + ...(refreshedAccountId ? { accountId: refreshedAccountId } : {}), + }; syncAccessToken = refreshResult.access; syncRefreshToken = refreshResult.refresh; syncExpiresAt = refreshResult.expires; @@ -1858,7 +1877,7 @@ export async function runDoctor( canSyncActiveAccount = true; storageFixChanged = true; fixChanged = true; - fixActions.push({ + supplementalFixActions.push({ key: "doctor-refresh", message: `Refreshed active account tokens for account ${activeIndex + 1}`, }); @@ -1886,7 +1905,7 @@ export async function runDoctor( ...(syncIdToken ? { idToken: syncIdToken } : {}), }; } else if (options.dryRun && canSyncActiveAccount) { - fixActions.push({ + supplementalFixActions.push({ key: "codex-active-sync", message: "Prepared Codex active-account sync (dry-run)", }); @@ -1896,16 +1915,62 @@ export async function runDoctor( } if (options.fix && storage && storage.accounts.length > 0 && storageFixChanged && !options.dryRun) { - const doctorAccountMutations = collectAccountStorageMutations( - originalDoctorAccounts, - storage.accounts, - ); await withAccountStorageTransaction(async (loadedStorage, persist) => { const nextStorage = loadedStorage ? structuredClone(loadedStorage) : createEmptyAccountStorage(); - applyAccountStorageMutations(nextStorage, doctorAccountMutations); - normalizeDoctorIndexes(nextStorage); + const transactionFixed = applyDoctorFixes(nextStorage, deps); + structuralFixActions = transactionFixed.actions; + let transactionChanged = transactionFixed.changed; + if (doctorRefreshMutation) { + const fallbackActiveIndex = deps.resolveActiveIndex(nextStorage, "codex"); + const fallbackTargetIndex = + fallbackActiveIndex >= 0 && fallbackActiveIndex < nextStorage.accounts.length + ? fallbackActiveIndex + : undefined; + const targetIndex = + findMatchingAccountIndex(nextStorage.accounts, doctorRefreshMutation.match, { + allowUniqueAccountIdFallbackWithoutEmail: true, + }) + ?? findMatchingAccountIndex(nextStorage.accounts, { + accountId: doctorRefreshMutation.accountId, + email: doctorRefreshMutation.email, + refreshToken: doctorRefreshMutation.refreshToken, + }, { + allowUniqueAccountIdFallbackWithoutEmail: true, + }) + ?? fallbackTargetIndex; + const target = targetIndex === undefined ? undefined : nextStorage.accounts[targetIndex]; + if (target) { + if (target.accessToken !== doctorRefreshMutation.accessToken) { + target.accessToken = doctorRefreshMutation.accessToken; + transactionChanged = true; + } + if (target.refreshToken !== doctorRefreshMutation.refreshToken) { + target.refreshToken = doctorRefreshMutation.refreshToken; + transactionChanged = true; + } + if (target.expiresAt !== doctorRefreshMutation.expiresAt) { + target.expiresAt = doctorRefreshMutation.expiresAt; + transactionChanged = true; + } + if (doctorRefreshMutation.email && target.email !== doctorRefreshMutation.email) { + target.email = doctorRefreshMutation.email; + transactionChanged = true; + } + if (deps.applyTokenAccountIdentity(target, doctorRefreshMutation.accountId)) { + transactionChanged = true; + } + } + } + if (normalizeDoctorIndexes(nextStorage)) { + transactionChanged = true; + } + if (!transactionChanged) { + storageFixChanged = false; + return; + } + storageFixChanged = true; await persist(nextStorage); }); } @@ -1913,7 +1978,7 @@ export async function runDoctor( if (pendingCodexActiveSync) { const synced = await setCodexCliActiveSelection(pendingCodexActiveSync); if (synced) { - fixActions.push({ + supplementalFixActions.push({ key: "codex-active-sync", message: "Synced manager active account into Codex auth state", }); @@ -1926,8 +1991,10 @@ export async function runDoctor( } } + const fixActions = [...structuralFixActions, ...supplementalFixActions]; + if (options.fix && storage && storage.accounts.length > 0) { - fixChanged = fixActions.length > 0; + fixChanged = storageFixChanged || fixActions.length > 0; addCheck({ key: "auto-fix", severity: fixChanged ? "warn" : "ok", diff --git a/test/repair-commands.test.ts b/test/repair-commands.test.ts index b8147f8c..fef81288 100644 --- a/test/repair-commands.test.ts +++ b/test/repair-commands.test.ts @@ -571,6 +571,7 @@ describe("repair-commands direct deps coverage", () => { it("runDoctor derives auto-fix state from the final action set", async () => { const now = Date.now(); + let persistedAccountStorage: unknown; loadAccountsMock.mockResolvedValueOnce({ version: 3, accounts: [ @@ -587,6 +588,30 @@ describe("repair-commands direct deps coverage", () => { activeIndex: 0, activeIndexByFamily: { codex: 0 }, }); + withAccountStorageTransactionMock.mockImplementation(async (handler) => + handler( + { + version: 3, + accounts: [ + { + email: "doctor@example.com", + refreshToken: "doctor-refresh", + accessToken: "concurrent-access", + expiresAt: now - 30_000, + accountId: "doctor-account", + accountIdSource: "manual" as const, + accountLabel: "Concurrent Label", + enabled: true, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }, + async (nextStorage: unknown) => { + persistedAccountStorage = nextStorage; + }, + ), + ); queuedRefreshMock.mockResolvedValueOnce({ type: "success", access: "doctor-access-next", @@ -594,6 +619,12 @@ describe("repair-commands direct deps coverage", () => { expires: now + 3_600_000, idToken: "doctor-id-next", }); + extractAccountEmailMock.mockImplementation((accessToken: string | undefined) => + accessToken === "doctor-access-next" ? "doctor-fresh@example.com" : "doctor@example.com" + ); + extractAccountIdMock.mockImplementation((accessToken: string | undefined) => + accessToken === "doctor-access-next" ? "doctor-token-account" : "doctor-account" + ); setCodexCliActiveSelectionMock.mockResolvedValueOnce(true); const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); @@ -605,6 +636,16 @@ describe("repair-commands direct deps coverage", () => { ); expect(exitCode).toBe(0); + expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(persistedAccountStorage).toMatchObject({ + accounts: [ + expect.objectContaining({ + accountLabel: "Concurrent Label", + accessToken: "doctor-access-next", + refreshToken: "doctor-refresh-next", + }), + ], + }); const payload = JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")) as { checks: Array<{ key: string; severity: string; message: string }>; fix: { From cae16c2d00b7ee371e474b9bd67c6751b586ee35 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 04:46:25 +0800 Subject: [PATCH 028/376] refactor: extract forecast command --- lib/codex-manager.ts | 391 ++--------------- lib/codex-manager/commands/forecast.ts | 462 ++++++++++++++++++++ test/codex-manager-forecast-command.test.ts | 142 ++++++ 3 files changed, 633 insertions(+), 362 deletions(-) create mode 100644 lib/codex-manager/commands/forecast.ts create mode 100644 test/codex-manager-forecast-command.test.ts diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 07fccee6..40315059 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -36,6 +36,7 @@ import { } from "./codex-cli/state.js"; import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; import { runCheckCommand } from "./codex-manager/commands/check.js"; +import { runForecastCommand } from "./codex-manager/commands/forecast.js"; import { runReportCommand } from "./codex-manager/commands/report.js"; import { runFeaturesCommand, @@ -2242,12 +2243,6 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { ); } -interface ForecastCliOptions { - live: boolean; - json: boolean; - model: string; -} - interface BestCliOptions { live: boolean; json: boolean; @@ -2272,20 +2267,6 @@ type ParsedArgsResult = | { ok: true; options: T } | { ok: false; message: string }; -function printForecastUsage(): void { - console.log( - [ - "Usage:", - " codex auth forecast [--live] [--json] [--model ]", - "", - "Options:", - " --live, -l Probe live quota headers via Codex backend", - " --json, -j Print machine-readable JSON output", - " --model, -m Probe model for live mode (default: gpt-5-codex)", - ].join("\n"), - ); -} - function printBestUsage(): void { console.log( [ @@ -2342,50 +2323,6 @@ function printVerifyFlaggedUsage(): void { ); } -function parseForecastArgs( - args: string[], -): ParsedArgsResult { - const options: ForecastCliOptions = { - live: false, - json: false, - model: "gpt-5-codex", - }; - - for (let i = 0; i < args.length; i += 1) { - const arg = args[i]; - if (!arg) continue; - if (!arg) continue; - if (arg === "--live" || arg === "-l") { - options.live = true; - continue; - } - if (arg === "--json" || arg === "-j") { - options.json = true; - continue; - } - if (arg === "--model" || arg === "-m") { - const value = args[i + 1]; - if (!value) { - return { ok: false, message: "Missing value for --model" }; - } - options.model = value; - i += 1; - continue; - } - if (arg.startsWith("--model=")) { - const value = arg.slice("--model=".length).trim(); - if (!value) { - return { ok: false, message: "Missing value for --model" }; - } - options.model = value; - continue; - } - return { ok: false, message: `Unknown option: ${arg}` }; - } - - return { ok: true, options }; -} - function parseBestArgs(args: string[]): ParsedArgsResult { const options: BestCliOptions = { live: false, @@ -2552,305 +2489,35 @@ function parseDoctorArgs(args: string[]): ParsedArgsResult { return { ok: true, options }; } -function serializeForecastResults( - results: ForecastAccountResult[], - liveQuotaByIndex: Map< - number, - Awaited> - >, - refreshFailures: Map, -): Array<{ - index: number; - label: string; - isCurrent: boolean; - availability: ForecastAccountResult["availability"]; - riskScore: number; - riskLevel: ForecastAccountResult["riskLevel"]; - waitMs: number; - reasons: string[]; - liveQuota?: { - status: number; - planType?: string; - activeLimit?: number; - model: string; - summary: string; - }; - refreshFailure?: TokenFailure; -}> { - return results.map((result) => { - const liveQuota = liveQuotaByIndex.get(result.index); - return { - index: result.index, - label: result.label, - isCurrent: result.isCurrent, - availability: result.availability, - riskScore: result.riskScore, - riskLevel: result.riskLevel, - waitMs: result.waitMs, - reasons: result.reasons, - liveQuota: liveQuota - ? { - status: liveQuota.status, - planType: liveQuota.planType, - activeLimit: liveQuota.activeLimit, - model: liveQuota.model, - summary: formatQuotaSnapshotLine(liveQuota), - } - : undefined, - refreshFailure: refreshFailures.get(result.index), - }; - }); -} - async function runForecast(args: string[]): Promise { - if (args.includes("--help") || args.includes("-h")) { - printForecastUsage(); - return 0; - } - - const parsedArgs = parseForecastArgs(args); - if (!parsedArgs.ok) { - console.error(parsedArgs.message); - printForecastUsage(); - return 1; - } - const options = parsedArgs.options; - const display = DEFAULT_DASHBOARD_DISPLAY_SETTINGS; - const quotaCache = options.live ? await loadQuotaCache() : null; - const workingQuotaCache = quotaCache ? cloneQuotaCacheData(quotaCache) : null; - let quotaCacheChanged = false; - - setStoragePath(null); - const storage = await loadAccounts(); - if (!storage || storage.accounts.length === 0) { - console.log("No accounts configured."); - return 0; - } - const quotaEmailFallbackState = - options.live && quotaCache - ? buildQuotaEmailFallbackState(storage.accounts) - : null; - - const now = Date.now(); - const activeIndex = resolveActiveIndex(storage, "codex"); - const refreshFailures = new Map(); - const liveQuotaByIndex = new Map< - number, - Awaited> - >(); - const probeErrors: string[] = []; - - for (let i = 0; i < storage.accounts.length; i += 1) { - const account = storage.accounts[i]; - if (!account || !options.live) continue; - if (account.enabled === false) continue; - - let probeAccessToken = account.accessToken; - let probeAccountId = - account.accountId ?? extractAccountId(account.accessToken); - if (!hasUsableAccessToken(account, now)) { - const refreshResult = await queuedRefresh(account.refreshToken); - if (refreshResult.type !== "success") { - refreshFailures.set(i, { - ...refreshResult, - message: normalizeFailureDetail( - refreshResult.message, - refreshResult.reason, - ), - }); - continue; - } - probeAccessToken = refreshResult.access; - probeAccountId = - account.accountId ?? extractAccountId(refreshResult.access); - } - - if (!probeAccessToken || !probeAccountId) { - probeErrors.push( - `${formatAccountLabel(account, i)}: missing accountId for live probe`, - ); - continue; - } - - try { - const liveQuota = await fetchCodexQuotaSnapshot({ - accountId: probeAccountId, - accessToken: probeAccessToken, - model: options.model, - }); - liveQuotaByIndex.set(i, liveQuota); - if (workingQuotaCache) { - const account = storage.accounts[i]; - if (account) { - quotaCacheChanged = - updateQuotaCacheForAccount( - workingQuotaCache, - account, - liveQuota, - storage.accounts, - quotaEmailFallbackState ?? undefined, - ) || quotaCacheChanged; - } - } - } catch (error) { - const message = normalizeFailureDetail( - error instanceof Error ? error.message : String(error), - undefined, - ); - probeErrors.push(`${formatAccountLabel(account, i)}: ${message}`); - } - } - - const forecastInputs = storage.accounts.map((account, index) => ({ - index, - account, - isCurrent: index === activeIndex, - now, - refreshFailure: refreshFailures.get(index), - liveQuota: liveQuotaByIndex.get(index), - })); - const forecastResults = evaluateForecastAccounts(forecastInputs); - const summary = summarizeForecast(forecastResults); - const recommendation = recommendForecastAccount(forecastResults); - - if (options.json) { - if (workingQuotaCache && quotaCacheChanged) { - await saveQuotaCache(workingQuotaCache); - } - console.log( - JSON.stringify( - { - command: "forecast", - model: options.model, - liveProbe: options.live, - summary, - recommendation, - probeErrors, - accounts: serializeForecastResults( - forecastResults, - liveQuotaByIndex, - refreshFailures, - ), - }, - null, - 2, - ), - ); - return 0; - } - - console.log( - stylePromptText( - `Best-account preview (${storage.accounts.length} account(s), model ${options.model}, live check ${options.live ? "on" : "off"})`, - "accent", - ), - ); - console.log( - formatResultSummary([ - { text: `${summary.ready} ready now`, tone: "success" }, - { text: `${summary.delayed} waiting`, tone: "warning" }, - { - text: `${summary.unavailable} unavailable`, - tone: summary.unavailable > 0 ? "danger" : "muted", - }, - { - text: `${summary.highRisk} high risk`, - tone: summary.highRisk > 0 ? "danger" : "muted", - }, - ]), - ); - console.log(""); - - for (const result of forecastResults) { - if (!display.showPerAccountRows) { - continue; - } - const currentTag = result.isCurrent ? " [current]" : ""; - const waitLabel = - result.waitMs > 0 - ? stylePromptText(`wait ${formatWaitTime(result.waitMs)}`, "muted") - : ""; - const indexLabel = stylePromptText(`${result.index + 1}.`, "accent"); - const accountLabel = stylePromptText( - `${result.label}${currentTag}`, - "accent", - ); - const riskLabel = stylePromptText( - `${result.riskLevel} risk (${result.riskScore})`, - riskTone(result.riskLevel), - ); - const availabilityLabel = stylePromptText( - result.availability, - availabilityTone(result.availability), - ); - const rowParts = [availabilityLabel, riskLabel]; - if (waitLabel) rowParts.push(waitLabel); - console.log( - `${indexLabel} ${accountLabel} ${stylePromptText("|", "muted")} ${joinStyledSegments(rowParts)}`, - ); - if (display.showForecastReasons && result.reasons.length > 0) { - console.log( - ` ${stylePromptText(result.reasons.slice(0, 3).join("; "), "muted")}`, - ); - } - const liveQuota = liveQuotaByIndex.get(result.index); - if (display.showQuotaDetails && liveQuota) { - console.log( - ` ${stylePromptText("quota:", "accent")} ${styleQuotaSummary(formatCompactQuotaSnapshot(liveQuota))}`, - ); - } - } - - if (!display.showPerAccountRows) { - console.log( - stylePromptText( - "Per-account lines are hidden in dashboard settings.", - "muted", - ), - ); - } - - if (display.showRecommendations) { - console.log(""); - if (recommendation.recommendedIndex !== null) { - const index = recommendation.recommendedIndex; - const account = forecastResults.find((result) => result.index === index); - if (account) { - console.log( - `${stylePromptText("Best next account:", "accent")} ${stylePromptText(`${index + 1} (${account.label})`, "success")}`, - ); - console.log( - `${stylePromptText("Why:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`, - ); - if (index !== activeIndex) { - console.log( - `${stylePromptText("Switch now with:", "accent")} codex auth switch ${index + 1}`, - ); - } - } - } else { - console.log( - `${stylePromptText("Note:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`, - ); - } - } - - if (display.showLiveProbeNotes && probeErrors.length > 0) { - console.log(""); - console.log( - stylePromptText(`Live check notes (${probeErrors.length}):`, "warning"), - ); - for (const error of probeErrors) { - console.log( - ` ${stylePromptText("-", "warning")} ${stylePromptText(error, "muted")}`, - ); - } - } - if (workingQuotaCache && quotaCacheChanged) { - await saveQuotaCache(workingQuotaCache); - } - - return 0; + return runForecastCommand(args, { + setStoragePath, + loadAccounts, + resolveActiveIndex, + loadQuotaCache, + saveQuotaCache, + cloneQuotaCacheData, + buildQuotaEmailFallbackState, + updateQuotaCacheForAccount, + hasUsableAccessToken, + queuedRefresh, + fetchCodexQuotaSnapshot, + normalizeFailureDetail, + formatAccountLabel, + extractAccountId, + evaluateForecastAccounts, + summarizeForecast, + recommendForecastAccount, + stylePromptText, + formatResultSummary, + styleQuotaSummary, + formatCompactQuotaSnapshot, + availabilityTone, + riskTone, + formatWaitTime, + defaultDisplay: DEFAULT_DASHBOARD_DISPLAY_SETTINGS, + formatQuotaSnapshotLine, + }); } type FixOutcome = diff --git a/lib/codex-manager/commands/forecast.ts b/lib/codex-manager/commands/forecast.ts new file mode 100644 index 00000000..5793285c --- /dev/null +++ b/lib/codex-manager/commands/forecast.ts @@ -0,0 +1,462 @@ +import type { DashboardDisplaySettings } from "../../dashboard-settings.js"; +import type { ForecastAccountResult } from "../../forecast.js"; +import type { QuotaCacheData } from "../../quota-cache.js"; +import type { CodexQuotaSnapshot } from "../../quota-probe.js"; +import type { AccountMetadataV3, AccountStorageV3 } from "../../storage.js"; +import type { TokenFailure, TokenResult } from "../../types.js"; + +interface ForecastCliOptions { + live: boolean; + json: boolean; + model: string; +} + +type ParsedArgsResult = + | { ok: true; options: T } + | { ok: false; message: string }; + +type PromptTone = "accent" | "success" | "warning" | "danger" | "muted"; +type QuotaEmailFallbackState = ReadonlyMap< + string, + { matchingCount: number; distinctAccountIds: Set } +>; + +export interface ForecastCommandDeps { + setStoragePath: (path: string | null) => void; + loadAccounts: () => Promise; + resolveActiveIndex: (storage: AccountStorageV3, family?: "codex") => number; + loadQuotaCache: () => Promise; + saveQuotaCache: (cache: QuotaCacheData) => Promise; + cloneQuotaCacheData: (cache: QuotaCacheData) => QuotaCacheData; + buildQuotaEmailFallbackState: ( + accounts: readonly Pick[], + ) => QuotaEmailFallbackState; + updateQuotaCacheForAccount: ( + cache: QuotaCacheData, + account: Pick, + snapshot: CodexQuotaSnapshot, + accounts: readonly Pick[], + emailFallbackState?: QuotaEmailFallbackState, + ) => boolean; + hasUsableAccessToken: ( + account: Pick, + now: number, + ) => boolean; + queuedRefresh: (refreshToken: string) => Promise; + fetchCodexQuotaSnapshot: (input: { + accountId: string; + accessToken: string; + model: string; + }) => Promise; + normalizeFailureDetail: ( + message: string | undefined, + reason: string | undefined, + ) => string; + formatAccountLabel: ( + account: Pick, + index: number, + ) => string; + extractAccountId: (accessToken: string | undefined) => string | undefined; + evaluateForecastAccounts: ( + inputs: Array<{ + index: number; + account: AccountMetadataV3; + isCurrent: boolean; + now: number; + refreshFailure?: TokenFailure; + liveQuota?: CodexQuotaSnapshot; + }>, + ) => ForecastAccountResult[]; + summarizeForecast: (results: ForecastAccountResult[]) => { + total: number; + ready: number; + delayed: number; + unavailable: number; + highRisk: number; + }; + recommendForecastAccount: (results: ForecastAccountResult[]) => { + recommendedIndex: number | null; + reason: string; + }; + stylePromptText: (text: string, tone: PromptTone) => string; + formatResultSummary: ( + segments: ReadonlyArray<{ text: string; tone: PromptTone }>, + ) => string; + styleQuotaSummary: (summary: string) => string; + formatCompactQuotaSnapshot: (snapshot: CodexQuotaSnapshot) => string; + availabilityTone: ( + availability: ForecastAccountResult["availability"], + ) => "success" | "warning" | "danger"; + riskTone: ( + level: ForecastAccountResult["riskLevel"], + ) => "success" | "warning" | "danger"; + formatWaitTime: (ms: number) => string; + defaultDisplay: DashboardDisplaySettings; + logInfo?: (message: string) => void; + logError?: (message: string) => void; + getNow?: () => number; +} + +function printForecastUsage(logInfo: (message: string) => void): void { + logInfo( + [ + "Usage:", + " codex auth forecast [--live] [--json] [--model ]", + "", + "Options:", + " --live, -l Probe live quota headers via Codex backend", + " --json, -j Print machine-readable JSON output", + " --model, -m Probe model for live mode (default: gpt-5-codex)", + ].join("\n"), + ); +} + +function parseForecastArgs( + args: string[], +): ParsedArgsResult { + const options: ForecastCliOptions = { + live: false, + json: false, + model: "gpt-5-codex", + }; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (!arg) continue; + if (arg === "--live" || arg === "-l") { + options.live = true; + continue; + } + if (arg === "--json" || arg === "-j") { + options.json = true; + continue; + } + if (arg === "--model" || arg === "-m") { + const value = args[i + 1]; + if (!value) return { ok: false, message: "Missing value for --model" }; + options.model = value; + i += 1; + continue; + } + if (arg.startsWith("--model=")) { + const value = arg.slice("--model=".length).trim(); + if (!value) return { ok: false, message: "Missing value for --model" }; + options.model = value; + continue; + } + return { ok: false, message: `Unknown option: ${arg}` }; + } + + return { ok: true, options }; +} + +function serializeForecastResults( + results: ForecastAccountResult[], + liveQuotaByIndex: Map, + refreshFailures: Map, +): Array<{ + index: number; + label: string; + isCurrent: boolean; + availability: ForecastAccountResult["availability"]; + riskScore: number; + riskLevel: ForecastAccountResult["riskLevel"]; + waitMs: number; + reasons: string[]; + liveQuota?: { + status: number; + planType?: string; + activeLimit?: number; + model: string; + summary: string; + }; + refreshFailure?: TokenFailure; +}> { + return results.map((result) => { + const liveQuota = liveQuotaByIndex.get(result.index); + return { + index: result.index, + label: result.label, + isCurrent: result.isCurrent, + availability: result.availability, + riskScore: result.riskScore, + riskLevel: result.riskLevel, + waitMs: result.waitMs, + reasons: result.reasons, + liveQuota: liveQuota + ? { + status: liveQuota.status, + planType: liveQuota.planType, + activeLimit: liveQuota.activeLimit, + model: liveQuota.model, + summary: deps_formatQuotaSnapshotLine(liveQuota), + } + : undefined, + refreshFailure: refreshFailures.get(result.index), + }; + }); +} + +let deps_formatQuotaSnapshotLine: (snapshot: CodexQuotaSnapshot) => string; + +export async function runForecastCommand( + args: string[], + deps: ForecastCommandDeps & { + formatQuotaSnapshotLine: (snapshot: CodexQuotaSnapshot) => string; + }, +): Promise { + deps_formatQuotaSnapshotLine = deps.formatQuotaSnapshotLine; + const logInfo = deps.logInfo ?? console.log; + const logError = deps.logError ?? console.error; + if (args.includes("--help") || args.includes("-h")) { + printForecastUsage(logInfo); + return 0; + } + + const parsedArgs = parseForecastArgs(args); + if (!parsedArgs.ok) { + logError(parsedArgs.message); + printForecastUsage(logInfo); + return 1; + } + const options = parsedArgs.options; + const display = deps.defaultDisplay; + const quotaCache = options.live ? await deps.loadQuotaCache() : null; + const workingQuotaCache = quotaCache + ? deps.cloneQuotaCacheData(quotaCache) + : null; + let quotaCacheChanged = false; + + deps.setStoragePath(null); + const storage = await deps.loadAccounts(); + if (!storage || storage.accounts.length === 0) { + logInfo("No accounts configured."); + return 0; + } + const quotaEmailFallbackState = + options.live && quotaCache + ? deps.buildQuotaEmailFallbackState(storage.accounts) + : null; + + const now = deps.getNow?.() ?? Date.now(); + const activeIndex = deps.resolveActiveIndex(storage, "codex"); + const refreshFailures = new Map(); + const liveQuotaByIndex = new Map(); + const probeErrors: string[] = []; + + for (let i = 0; i < storage.accounts.length; i += 1) { + const account = storage.accounts[i]; + if (!account || !options.live) continue; + if (account.enabled === false) continue; + + let probeAccessToken = account.accessToken; + let probeAccountId = + account.accountId ?? deps.extractAccountId(account.accessToken); + if (!deps.hasUsableAccessToken(account, now)) { + const refreshResult = await deps.queuedRefresh(account.refreshToken); + if (refreshResult.type !== "success") { + refreshFailures.set(i, { + ...refreshResult, + message: deps.normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ), + }); + continue; + } + probeAccessToken = refreshResult.access; + probeAccountId = + account.accountId ?? deps.extractAccountId(refreshResult.access); + } + + if (!probeAccessToken || !probeAccountId) { + probeErrors.push( + `${deps.formatAccountLabel(account, i)}: missing accountId for live probe`, + ); + continue; + } + + try { + const liveQuota = await deps.fetchCodexQuotaSnapshot({ + accountId: probeAccountId, + accessToken: probeAccessToken, + model: options.model, + }); + liveQuotaByIndex.set(i, liveQuota); + if (workingQuotaCache) { + const nextAccount = storage.accounts[i]; + if (nextAccount) { + quotaCacheChanged = + deps.updateQuotaCacheForAccount( + workingQuotaCache, + nextAccount, + liveQuota, + storage.accounts, + quotaEmailFallbackState ?? undefined, + ) || quotaCacheChanged; + } + } + } catch (error) { + const message = deps.normalizeFailureDetail( + error instanceof Error ? error.message : String(error), + undefined, + ); + probeErrors.push(`${deps.formatAccountLabel(account, i)}: ${message}`); + } + } + + const forecastInputs = storage.accounts.map((account, index) => ({ + index, + account, + isCurrent: index === activeIndex, + now, + refreshFailure: refreshFailures.get(index), + liveQuota: liveQuotaByIndex.get(index), + })); + const forecastResults = deps.evaluateForecastAccounts(forecastInputs); + const summary = deps.summarizeForecast(forecastResults); + const recommendation = deps.recommendForecastAccount(forecastResults); + + if (options.json) { + if (workingQuotaCache && quotaCacheChanged) { + await deps.saveQuotaCache(workingQuotaCache); + } + logInfo( + JSON.stringify( + { + command: "forecast", + model: options.model, + liveProbe: options.live, + summary, + recommendation, + probeErrors, + accounts: serializeForecastResults( + forecastResults, + liveQuotaByIndex, + refreshFailures, + ), + }, + null, + 2, + ), + ); + return 0; + } + + logInfo( + deps.stylePromptText( + `Best-account preview (${storage.accounts.length} account(s), model ${options.model}, live check ${options.live ? "on" : "off"})`, + "accent", + ), + ); + logInfo( + deps.formatResultSummary([ + { text: `${summary.ready} ready now`, tone: "success" }, + { text: `${summary.delayed} waiting`, tone: "warning" }, + { + text: `${summary.unavailable} unavailable`, + tone: summary.unavailable > 0 ? "danger" : "muted", + }, + { + text: `${summary.highRisk} high risk`, + tone: summary.highRisk > 0 ? "danger" : "muted", + }, + ]), + ); + logInfo(""); + + for (const result of forecastResults) { + if (!display.showPerAccountRows) continue; + const currentTag = result.isCurrent ? " [current]" : ""; + const waitLabel = + result.waitMs > 0 + ? deps.stylePromptText( + `wait ${deps.formatWaitTime(result.waitMs)}`, + "muted", + ) + : ""; + const indexLabel = deps.stylePromptText(`${result.index + 1}.`, "accent"); + const accountLabel = deps.stylePromptText( + `${result.label}${currentTag}`, + "accent", + ); + const riskLabel = deps.stylePromptText( + `${result.riskLevel} risk (${result.riskScore})`, + deps.riskTone(result.riskLevel), + ); + const availabilityLabel = deps.stylePromptText( + result.availability, + deps.availabilityTone(result.availability), + ); + const rowParts = [availabilityLabel, riskLabel]; + if (waitLabel) rowParts.push(waitLabel); + logInfo( + `${indexLabel} ${accountLabel} ${deps.stylePromptText("|", "muted")} ${rowParts.join(deps.stylePromptText(" | ", "muted"))}`, + ); + if (display.showForecastReasons && result.reasons.length > 0) { + logInfo( + ` ${deps.stylePromptText(result.reasons.slice(0, 3).join("; "), "muted")}`, + ); + } + const liveQuota = liveQuotaByIndex.get(result.index); + if (display.showQuotaDetails && liveQuota) { + logInfo( + ` ${deps.stylePromptText("quota:", "accent")} ${deps.styleQuotaSummary(deps.formatCompactQuotaSnapshot(liveQuota))}`, + ); + } + } + + if (!display.showPerAccountRows) { + logInfo( + deps.stylePromptText( + "Per-account lines are hidden in dashboard settings.", + "muted", + ), + ); + } + + if (display.showRecommendations) { + logInfo(""); + if (recommendation.recommendedIndex !== null) { + const index = recommendation.recommendedIndex; + const account = forecastResults.find((result) => result.index === index); + if (account) { + logInfo( + `${deps.stylePromptText("Best next account:", "accent")} ${deps.stylePromptText(`${index + 1} (${account.label})`, "success")}`, + ); + logInfo( + `${deps.stylePromptText("Why:", "accent")} ${deps.stylePromptText(recommendation.reason, "muted")}`, + ); + if (index !== activeIndex) { + logInfo( + `${deps.stylePromptText("Switch now with:", "accent")} codex auth switch ${index + 1}`, + ); + } + } + } else { + logInfo( + `${deps.stylePromptText("Note:", "accent")} ${deps.stylePromptText(recommendation.reason, "muted")}`, + ); + } + } + + if (display.showLiveProbeNotes && probeErrors.length > 0) { + logInfo(""); + logInfo( + deps.stylePromptText( + `Live check notes (${probeErrors.length}):`, + "warning", + ), + ); + for (const error of probeErrors) { + logInfo( + ` ${deps.stylePromptText("-", "warning")} ${deps.stylePromptText(error, "muted")}`, + ); + } + } + if (workingQuotaCache && quotaCacheChanged) { + await deps.saveQuotaCache(workingQuotaCache); + } + + return 0; +} diff --git a/test/codex-manager-forecast-command.test.ts b/test/codex-manager-forecast-command.test.ts new file mode 100644 index 00000000..6d0d5128 --- /dev/null +++ b/test/codex-manager-forecast-command.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it, vi } from "vitest"; +import { + type ForecastCommandDeps, + runForecastCommand, +} from "../lib/codex-manager/commands/forecast.js"; +import type { AccountStorageV3 } from "../lib/storage.js"; + +function createStorage(): AccountStorageV3 { + return { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "forecast@example.com", + refreshToken: "refresh-forecast", + accessToken: "access-forecast", + expiresAt: Date.now() + 60_000, + addedAt: 1, + lastUsed: 1, + enabled: true, + }, + ], + }; +} + +function createDeps( + overrides: Partial< + ForecastCommandDeps & { + formatQuotaSnapshotLine: (snapshot: unknown) => string; + } + > = {}, +): ForecastCommandDeps & { + formatQuotaSnapshotLine: (snapshot: unknown) => string; +} { + return { + setStoragePath: vi.fn(), + loadAccounts: vi.fn(async () => createStorage()), + resolveActiveIndex: vi.fn(() => 0), + loadQuotaCache: vi.fn(async () => ({ byAccountId: {}, byEmail: {} })), + saveQuotaCache: vi.fn(async () => undefined), + cloneQuotaCacheData: vi.fn((cache) => structuredClone(cache)), + buildQuotaEmailFallbackState: vi.fn(() => new Map()), + updateQuotaCacheForAccount: vi.fn(() => false), + hasUsableAccessToken: vi.fn(() => true), + queuedRefresh: vi.fn(async () => ({ + type: "success", + access: "access-forecast", + refresh: "refresh-forecast", + expires: Date.now() + 60_000, + })), + fetchCodexQuotaSnapshot: vi.fn(async () => ({ + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + })), + normalizeFailureDetail: vi.fn((message) => message ?? "unknown"), + formatAccountLabel: vi.fn( + (_account, index) => `${index + 1}. forecast@example.com`, + ), + extractAccountId: vi.fn(() => "account-id"), + evaluateForecastAccounts: vi.fn(() => [ + { + index: 0, + label: "1. forecast@example.com", + isCurrent: true, + availability: "ready", + riskScore: 0, + riskLevel: "low", + waitMs: 0, + reasons: ["healthy"], + }, + ]), + summarizeForecast: vi.fn(() => ({ + total: 1, + ready: 1, + delayed: 0, + unavailable: 0, + highRisk: 0, + })), + recommendForecastAccount: vi.fn(() => ({ + recommendedIndex: 0, + reason: "lowest risk", + })), + stylePromptText: vi.fn((text) => text), + formatResultSummary: vi.fn((segments) => + segments.map((segment) => segment.text).join(" | "), + ), + styleQuotaSummary: vi.fn((summary) => summary), + formatCompactQuotaSnapshot: vi.fn(() => "5h 75%"), + availabilityTone: vi.fn(() => "success"), + riskTone: vi.fn(() => "success"), + formatWaitTime: vi.fn(() => "1m"), + defaultDisplay: { + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }, + formatQuotaSnapshotLine: vi.fn(() => "quota summary"), + logInfo: vi.fn(), + logError: vi.fn(), + getNow: vi.fn(() => 1_000), + ...overrides, + } as ForecastCommandDeps & { + formatQuotaSnapshotLine: (snapshot: unknown) => string; + }; +} + +describe("runForecastCommand", () => { + it("prints usage for help", async () => { + const deps = createDeps(); + const result = await runForecastCommand(["--help"], deps); + expect(result).toBe(0); + expect(deps.logInfo).toHaveBeenCalledWith( + expect.stringContaining("codex auth forecast"), + ); + }); + + it("rejects invalid options", async () => { + const deps = createDeps(); + const result = await runForecastCommand(["--bogus"], deps); + expect(result).toBe(1); + expect(deps.logError).toHaveBeenCalledWith("Unknown option: --bogus"); + }); + + it("prints json output for populated storage", async () => { + const deps = createDeps(); + const result = await runForecastCommand(["--json"], deps); + expect(result).toBe(0); + expect(deps.logInfo).toHaveBeenCalledWith( + expect.stringContaining('"command": "forecast"'), + ); + }); +}); From 2cb694bbb1b1b8b6b7a67f9b3ee21b11d3b9c801 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 04:47:44 +0800 Subject: [PATCH 029/376] test wrapper cleanup retry enotempty --- test/codex-multi-auth-bin-wrapper.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/codex-multi-auth-bin-wrapper.test.ts b/test/codex-multi-auth-bin-wrapper.test.ts index 3fef8309..3dc039eb 100644 --- a/test/codex-multi-auth-bin-wrapper.test.ts +++ b/test/codex-multi-auth-bin-wrapper.test.ts @@ -16,7 +16,7 @@ function isRetriableFsError(error: unknown): boolean { return false; } const { code } = error as { code?: unknown }; - return code === "EBUSY" || code === "EPERM"; + return code === "EBUSY" || code === "EPERM" || code === "ENOTEMPTY"; } async function removeDirectoryWithRetry(dir: string): Promise { From 133507cdd377ce32b201abc2f627d96c32314d10 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 04:49:44 +0800 Subject: [PATCH 030/376] docs align beginner command references --- docs/reference/commands.md | 6 +++--- test/documentation.test.ts | 17 +++++++++++++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 8181b6e4..4bee3f73 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -61,10 +61,10 @@ Compatibility aliases are supported: | Flag | Applies to | Meaning | | --- | --- | --- | | `--manual`, `--no-browser` | login | Skip browser launch and use manual callback flow | -| `--json` | verify-flagged, forecast, report, fix, doctor | Print machine-readable output | -| `--live` | forecast, report, fix | Use live probe before decisions/output | +| `--json` | verify-flagged, best, forecast, report, fix, doctor | Print machine-readable output | +| `--live` | best, forecast, report, fix | Use live probe before decisions/output | | `--dry-run` | verify-flagged, fix, doctor | Preview without writing storage | -| `--model ` | forecast, report, fix | Specify model for live probe paths | +| `--model ` | best, forecast, report, fix | Specify model for live probe paths | | `--out ` | report | Write report output to file | | `--fix` | doctor | Apply safe repairs | | `--no-restore` | verify-flagged | Verify only; do not restore healthy flagged accounts | diff --git a/test/documentation.test.ts b/test/documentation.test.ts index 252b1ff2..aa300513 100644 --- a/test/documentation.test.ts +++ b/test/documentation.test.ts @@ -249,14 +249,27 @@ describe("Documentation Integrity", () => { const manager = read(managerPath); expect(readme).toContain("codex auth fix --live --model gpt-5-codex"); - expect(commandRef).toContain("| `--live` | forecast, report, fix |"); expect(commandRef).toContain( - "| `--model ` | forecast, report, fix |", + "| `--json` | verify-flagged, best, forecast, report, fix, doctor |", + ); + expect(commandRef).toContain("| `--live` | best, forecast, report, fix |"); + expect(commandRef).toContain( + "| `--model ` | best, forecast, report, fix |", ); expect(manager).toContain("codex auth login"); expect(manager).toContain( "codex auth fix [--dry-run] [--json] [--live] [--model ]", ); + expect(manager).toContain("Next steps:"); + expect(manager).toContain( + "codex auth status Check that the wrapper is active.", + ); + expect(manager).toContain( + "codex auth check Confirm your saved accounts look healthy.", + ); + expect(manager).toContain( + "codex auth list Review saved accounts before switching.", + ); expect(manager).toContain( "Missing index. Usage: codex auth switch ", ); From f12e002a2e01c1500cc262e8985b2e5875cab19d Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 04:52:33 +0800 Subject: [PATCH 031/376] refactor: extract verify flagged command --- lib/codex-manager.ts | 333 ++------------- lib/codex-manager/commands/verify-flagged.ts | 396 ++++++++++++++++++ ...dex-manager-verify-flagged-command.test.ts | 113 +++++ 3 files changed, 535 insertions(+), 307 deletions(-) create mode 100644 lib/codex-manager/commands/verify-flagged.ts create mode 100644 test/codex-manager-verify-flagged-command.test.ts diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 40315059..8e8d58e4 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -43,6 +43,10 @@ import { runStatusCommand, } from "./codex-manager/commands/status.js"; import { runSwitchCommand } from "./codex-manager/commands/switch.js"; +import { + runVerifyFlaggedCommand, + type VerifyFlaggedCliOptions, +} from "./codex-manager/commands/verify-flagged.js"; import { applyUiThemeFromDashboardSettings, configureUnifiedSettings, @@ -2257,12 +2261,6 @@ interface FixCliOptions { model: string; } -interface VerifyFlaggedCliOptions { - dryRun: boolean; - json: boolean; - restore: boolean; -} - type ParsedArgsResult = | { ok: true; options: T } | { ok: false; message: string }; @@ -2552,13 +2550,6 @@ function summarizeFixReports(reports: FixAccountReport[]): { return { healthy, disabled, warnings, skipped }; } -interface VerifyFlaggedReport { - index: number; - label: string; - outcome: "restored" | "healthy-flagged" | "still-flagged" | "restore-skipped"; - message: string; -} - function createEmptyAccountStorage(): AccountStorageV3 { const activeIndexByFamily: Partial> = {}; for (const family of MODEL_FAMILIES) { @@ -2718,300 +2709,28 @@ function upsertRecoveredFlaggedAccount( } async function runVerifyFlagged(args: string[]): Promise { - if (args.includes("--help") || args.includes("-h")) { - printVerifyFlaggedUsage(); - return 0; - } - - const parsedArgs = parseVerifyFlaggedArgs(args); - if (!parsedArgs.ok) { - console.error(parsedArgs.message); - printVerifyFlaggedUsage(); - return 1; - } - const options = parsedArgs.options; - - setStoragePath(null); - const flaggedStorage = await loadFlaggedAccounts(); - if (flaggedStorage.accounts.length === 0) { - if (options.json) { - console.log( - JSON.stringify( - { - command: "verify-flagged", - total: 0, - restored: 0, - healthyFlagged: 0, - stillFlagged: 0, - changed: false, - dryRun: options.dryRun, - restore: options.restore, - reports: [] as VerifyFlaggedReport[], - }, - null, - 2, - ), - ); - return 0; - } - console.log("No flagged accounts to check."); - return 0; - } - - let storageChanged = false; - let flaggedChanged = false; - const reports: VerifyFlaggedReport[] = []; - const nextFlaggedAccounts: FlaggedAccountMetadataV1[] = []; - const now = Date.now(); - const refreshChecks: Array<{ - index: number; - flagged: FlaggedAccountMetadataV1; - label: string; - result: Awaited>; - }> = []; - - for (let i = 0; i < flaggedStorage.accounts.length; i += 1) { - const flagged = flaggedStorage.accounts[i]; - if (!flagged) continue; - const label = formatAccountLabel(flagged, i); - refreshChecks.push({ - index: i, - flagged, - label, - result: await queuedRefresh(flagged.refreshToken), - }); - } - - const applyRefreshChecks = (storage: AccountStorageV3): void => { - for (const check of refreshChecks) { - const { index: i, flagged, label, result } = check; - if (result.type === "success") { - if (!options.restore) { - const tokenAccountId = extractAccountId(result.access); - const nextIdentity = resolveStoredAccountIdentity( - flagged.accountId, - flagged.accountIdSource, - tokenAccountId, - ); - const nextFlagged: FlaggedAccountMetadataV1 = { - ...flagged, - refreshToken: result.refresh, - accessToken: result.access, - expiresAt: result.expires, - accountId: nextIdentity.accountId, - accountIdSource: nextIdentity.accountIdSource, - email: - sanitizeEmail( - extractAccountEmail(result.access, result.idToken), - ) ?? flagged.email, - lastUsed: now, - lastError: undefined, - }; - nextFlaggedAccounts.push(nextFlagged); - if (JSON.stringify(nextFlagged) !== JSON.stringify(flagged)) { - flaggedChanged = true; - } - reports.push({ - index: i, - label, - outcome: "healthy-flagged", - message: - "session is healthy (left in flagged list due to --no-restore)", - }); - continue; - } - - const upsertResult = upsertRecoveredFlaggedAccount( - storage, - flagged, - result, - now, - ); - if (upsertResult.restored) { - storageChanged = storageChanged || upsertResult.changed; - flaggedChanged = true; - reports.push({ - index: i, - label, - outcome: "restored", - message: upsertResult.message, - }); - continue; - } - - const tokenAccountId = extractAccountId(result.access); - const nextIdentity = resolveStoredAccountIdentity( - flagged.accountId, - flagged.accountIdSource, - tokenAccountId, - ); - const updatedFlagged: FlaggedAccountMetadataV1 = { - ...flagged, - refreshToken: result.refresh, - accessToken: result.access, - expiresAt: result.expires, - accountId: nextIdentity.accountId, - accountIdSource: nextIdentity.accountIdSource, - email: - sanitizeEmail(extractAccountEmail(result.access, result.idToken)) ?? - flagged.email, - lastUsed: now, - lastError: upsertResult.message, - }; - nextFlaggedAccounts.push(updatedFlagged); - if (JSON.stringify(updatedFlagged) !== JSON.stringify(flagged)) { - flaggedChanged = true; - } - reports.push({ - index: i, - label, - outcome: "restore-skipped", - message: upsertResult.message, - }); - continue; - } - - const detail = normalizeFailureDetail(result.message, result.reason); - const failedFlagged: FlaggedAccountMetadataV1 = { - ...flagged, - lastError: detail, - }; - nextFlaggedAccounts.push(failedFlagged); - if ((flagged.lastError ?? "") !== detail) { - flaggedChanged = true; - } - reports.push({ - index: i, - label, - outcome: "still-flagged", - message: detail, - }); - } - }; - - if (options.restore) { - if (options.dryRun) { - applyRefreshChecks((await loadAccounts()) ?? createEmptyAccountStorage()); - } else { - await withAccountAndFlaggedStorageTransaction( - async (loadedStorage, persist) => { - const nextStorage = loadedStorage - ? structuredClone(loadedStorage) - : createEmptyAccountStorage(); - applyRefreshChecks(nextStorage); - if (!storageChanged) { - return; - } - normalizeDoctorIndexes(nextStorage); - await persist(nextStorage, { - version: 1, - accounts: nextFlaggedAccounts, - }); - }, - ); - } - } else { - applyRefreshChecks(createEmptyAccountStorage()); - } - - const remainingFlagged = nextFlaggedAccounts.length; - const restored = reports.filter( - (report) => report.outcome === "restored", - ).length; - const healthyFlagged = reports.filter( - (report) => report.outcome === "healthy-flagged", - ).length; - const stillFlagged = reports.filter( - (report) => report.outcome === "still-flagged", - ).length; - const changed = storageChanged || flaggedChanged; - - if ( - !options.dryRun && - flaggedChanged && - (!options.restore || !storageChanged) - ) { - await saveFlaggedAccounts({ - version: 1, - accounts: nextFlaggedAccounts, - }); - } - - if (options.json) { - console.log( - JSON.stringify( - { - command: "verify-flagged", - total: flaggedStorage.accounts.length, - restored, - healthyFlagged, - stillFlagged, - remainingFlagged, - changed, - dryRun: options.dryRun, - restore: options.restore, - reports, - }, - null, - 2, - ), - ); - return 0; - } - - console.log( - stylePromptText( - `Checking ${flaggedStorage.accounts.length} flagged account(s)...`, - "accent", - ), - ); - for (const report of reports) { - const tone = - report.outcome === "restored" - ? "success" - : report.outcome === "healthy-flagged" - ? "warning" - : report.outcome === "restore-skipped" - ? "warning" - : "danger"; - const marker = - report.outcome === "restored" - ? "✓" - : report.outcome === "healthy-flagged" - ? "!" - : report.outcome === "restore-skipped" - ? "!" - : "✗"; - console.log( - `${stylePromptText(marker, tone)} ${stylePromptText(`${report.index + 1}. ${report.label}`, "accent")} ${stylePromptText("|", "muted")} ${styleAccountDetailText(report.message, tone)}`, - ); - } - console.log(""); - console.log( - formatResultSummary([ - { - text: `${restored} restored`, - tone: restored > 0 ? "success" : "muted", - }, - { - text: `${healthyFlagged} healthy (kept flagged)`, - tone: healthyFlagged > 0 ? "warning" : "muted", - }, - { - text: `${stillFlagged} still flagged`, - tone: stillFlagged > 0 ? "danger" : "muted", - }, - ]), - ); - if (options.dryRun) { - console.log( - stylePromptText("Preview only: no changes were saved.", "warning"), - ); - } else if (!changed) { - console.log(stylePromptText("No storage changes were needed.", "muted")); - } - - return 0; + return runVerifyFlaggedCommand(args, { + setStoragePath, + loadFlaggedAccounts, + loadAccounts, + queuedRefresh, + parseVerifyFlaggedArgs, + printVerifyFlaggedUsage, + createEmptyAccountStorage, + upsertRecoveredFlaggedAccount, + resolveStoredAccountIdentity, + extractAccountId, + extractAccountEmail, + sanitizeEmail, + normalizeFailureDetail, + withAccountAndFlaggedStorageTransaction, + normalizeDoctorIndexes, + saveFlaggedAccounts, + formatAccountLabel, + stylePromptText, + styleAccountDetailText, + formatResultSummary, + }); } async function runFix(args: string[]): Promise { diff --git a/lib/codex-manager/commands/verify-flagged.ts b/lib/codex-manager/commands/verify-flagged.ts new file mode 100644 index 00000000..1a3d98f7 --- /dev/null +++ b/lib/codex-manager/commands/verify-flagged.ts @@ -0,0 +1,396 @@ +import type { + AccountStorageV3, + FlaggedAccountMetadataV1, +} from "../../storage.js"; +import type { TokenResult } from "../../types.js"; + +export interface VerifyFlaggedCliOptions { + dryRun: boolean; + json: boolean; + restore: boolean; +} + +type ParsedArgsResult = + | { ok: true; options: T } + | { ok: false; message: string }; + +export interface VerifyFlaggedReport { + index: number; + label: string; + outcome: "restored" | "healthy-flagged" | "still-flagged" | "restore-skipped"; + message: string; +} + +export interface VerifyFlaggedCommandDeps { + setStoragePath: (path: string | null) => void; + loadFlaggedAccounts: () => Promise<{ + version: 1; + accounts: FlaggedAccountMetadataV1[]; + }>; + loadAccounts: () => Promise; + queuedRefresh: (refreshToken: string) => Promise; + parseVerifyFlaggedArgs: ( + args: string[], + ) => ParsedArgsResult; + printVerifyFlaggedUsage: () => void; + createEmptyAccountStorage: () => AccountStorageV3; + upsertRecoveredFlaggedAccount: ( + storage: AccountStorageV3, + flagged: FlaggedAccountMetadataV1, + refreshResult: Extract, + now: number, + ) => { restored: boolean; changed: boolean; message: string }; + resolveStoredAccountIdentity: ( + accountId: string | undefined, + accountIdSource: FlaggedAccountMetadataV1["accountIdSource"], + tokenAccountId: string | undefined, + ) => { + accountId?: string; + accountIdSource?: FlaggedAccountMetadataV1["accountIdSource"]; + }; + extractAccountId: (accessToken: string | undefined) => string | undefined; + extractAccountEmail: ( + accessToken: string | undefined, + idToken: string | undefined, + ) => string | undefined; + sanitizeEmail: (email: string | undefined) => string | undefined; + normalizeFailureDetail: ( + message: string | undefined, + reason: string | undefined, + ) => string; + withAccountAndFlaggedStorageTransaction: ( + callback: ( + loadedStorage: AccountStorageV3 | null, + persist: ( + nextStorage: AccountStorageV3, + nextFlagged: { version: 1; accounts: FlaggedAccountMetadataV1[] }, + ) => Promise, + ) => Promise, + ) => Promise; + normalizeDoctorIndexes: (storage: AccountStorageV3) => void; + saveFlaggedAccounts: (data: { + version: 1; + accounts: FlaggedAccountMetadataV1[]; + }) => Promise; + formatAccountLabel: ( + account: Pick< + FlaggedAccountMetadataV1, + "email" | "accountLabel" | "accountId" + >, + index: number, + ) => string; + stylePromptText: ( + text: string, + tone: "accent" | "success" | "warning" | "danger" | "muted", + ) => string; + styleAccountDetailText: ( + detail: string, + fallbackTone?: "accent" | "success" | "warning" | "danger" | "muted", + ) => string; + formatResultSummary: ( + segments: ReadonlyArray<{ + text: string; + tone: "accent" | "success" | "warning" | "danger" | "muted"; + }>, + ) => string; + logInfo?: (message: string) => void; + logError?: (message: string) => void; + getNow?: () => number; +} + +export async function runVerifyFlaggedCommand( + args: string[], + deps: VerifyFlaggedCommandDeps, +): Promise { + const logInfo = deps.logInfo ?? console.log; + const logError = deps.logError ?? console.error; + if (args.includes("--help") || args.includes("-h")) { + deps.printVerifyFlaggedUsage(); + return 0; + } + + const parsedArgs = deps.parseVerifyFlaggedArgs(args); + if (!parsedArgs.ok) { + logError(parsedArgs.message); + deps.printVerifyFlaggedUsage(); + return 1; + } + const options = parsedArgs.options; + + deps.setStoragePath(null); + const flaggedStorage = await deps.loadFlaggedAccounts(); + if (flaggedStorage.accounts.length === 0) { + if (options.json) { + logInfo( + JSON.stringify( + { + command: "verify-flagged", + total: 0, + restored: 0, + healthyFlagged: 0, + stillFlagged: 0, + changed: false, + dryRun: options.dryRun, + restore: options.restore, + reports: [] as VerifyFlaggedReport[], + }, + null, + 2, + ), + ); + return 0; + } + logInfo("No flagged accounts to check."); + return 0; + } + + let storageChanged = false; + let flaggedChanged = false; + const reports: VerifyFlaggedReport[] = []; + const nextFlaggedAccounts: FlaggedAccountMetadataV1[] = []; + const now = deps.getNow?.() ?? Date.now(); + const refreshChecks: Array<{ + index: number; + flagged: FlaggedAccountMetadataV1; + label: string; + result: TokenResult; + }> = []; + + for (let i = 0; i < flaggedStorage.accounts.length; i += 1) { + const flagged = flaggedStorage.accounts[i]; + if (!flagged) continue; + const label = deps.formatAccountLabel(flagged, i); + refreshChecks.push({ + index: i, + flagged, + label, + result: await deps.queuedRefresh(flagged.refreshToken), + }); + } + + const applyRefreshChecks = (storage: AccountStorageV3): void => { + for (const check of refreshChecks) { + const { index: i, flagged, label, result } = check; + if (result.type === "success") { + if (!options.restore) { + const tokenAccountId = deps.extractAccountId(result.access); + const nextIdentity = deps.resolveStoredAccountIdentity( + flagged.accountId, + flagged.accountIdSource, + tokenAccountId, + ); + const nextFlagged: FlaggedAccountMetadataV1 = { + ...flagged, + refreshToken: result.refresh, + accessToken: result.access, + expiresAt: result.expires, + accountId: nextIdentity.accountId, + accountIdSource: nextIdentity.accountIdSource, + email: + deps.sanitizeEmail( + deps.extractAccountEmail(result.access, result.idToken), + ) ?? flagged.email, + lastUsed: now, + lastError: undefined, + }; + nextFlaggedAccounts.push(nextFlagged); + if (JSON.stringify(nextFlagged) !== JSON.stringify(flagged)) + flaggedChanged = true; + reports.push({ + index: i, + label, + outcome: "healthy-flagged", + message: + "session is healthy (left in flagged list due to --no-restore)", + }); + continue; + } + + const upsertResult = deps.upsertRecoveredFlaggedAccount( + storage, + flagged, + result, + now, + ); + if (upsertResult.restored) { + storageChanged = storageChanged || upsertResult.changed; + flaggedChanged = true; + reports.push({ + index: i, + label, + outcome: "restored", + message: upsertResult.message, + }); + continue; + } + + const tokenAccountId = deps.extractAccountId(result.access); + const nextIdentity = deps.resolveStoredAccountIdentity( + flagged.accountId, + flagged.accountIdSource, + tokenAccountId, + ); + const updatedFlagged: FlaggedAccountMetadataV1 = { + ...flagged, + refreshToken: result.refresh, + accessToken: result.access, + expiresAt: result.expires, + accountId: nextIdentity.accountId, + accountIdSource: nextIdentity.accountIdSource, + email: + deps.sanitizeEmail( + deps.extractAccountEmail(result.access, result.idToken), + ) ?? flagged.email, + lastUsed: now, + lastError: upsertResult.message, + }; + nextFlaggedAccounts.push(updatedFlagged); + if (JSON.stringify(updatedFlagged) !== JSON.stringify(flagged)) + flaggedChanged = true; + reports.push({ + index: i, + label, + outcome: "restore-skipped", + message: upsertResult.message, + }); + continue; + } + + const detail = deps.normalizeFailureDetail(result.message, result.reason); + const failedFlagged: FlaggedAccountMetadataV1 = { + ...flagged, + lastError: detail, + }; + nextFlaggedAccounts.push(failedFlagged); + if ((flagged.lastError ?? "") !== detail) flaggedChanged = true; + reports.push({ + index: i, + label, + outcome: "still-flagged", + message: detail, + }); + } + }; + + if (options.restore) { + if (options.dryRun) { + applyRefreshChecks( + (await deps.loadAccounts()) ?? deps.createEmptyAccountStorage(), + ); + } else { + await deps.withAccountAndFlaggedStorageTransaction( + async (loadedStorage, persist) => { + const nextStorage = loadedStorage + ? structuredClone(loadedStorage) + : deps.createEmptyAccountStorage(); + applyRefreshChecks(nextStorage); + if (!storageChanged) return; + deps.normalizeDoctorIndexes(nextStorage); + await persist(nextStorage, { + version: 1, + accounts: nextFlaggedAccounts, + }); + }, + ); + } + } else { + applyRefreshChecks(deps.createEmptyAccountStorage()); + } + + const remainingFlagged = nextFlaggedAccounts.length; + const restored = reports.filter( + (report) => report.outcome === "restored", + ).length; + const healthyFlagged = reports.filter( + (report) => report.outcome === "healthy-flagged", + ).length; + const stillFlagged = reports.filter( + (report) => report.outcome === "still-flagged", + ).length; + const changed = storageChanged || flaggedChanged; + + if ( + !options.dryRun && + flaggedChanged && + (!options.restore || !storageChanged) + ) { + await deps.saveFlaggedAccounts({ + version: 1, + accounts: nextFlaggedAccounts, + }); + } + + if (options.json) { + logInfo( + JSON.stringify( + { + command: "verify-flagged", + total: flaggedStorage.accounts.length, + restored, + healthyFlagged, + stillFlagged, + remainingFlagged, + changed, + dryRun: options.dryRun, + restore: options.restore, + reports, + }, + null, + 2, + ), + ); + return 0; + } + + logInfo( + deps.stylePromptText( + `Checking ${flaggedStorage.accounts.length} flagged account(s)...`, + "accent", + ), + ); + for (const report of reports) { + const tone = + report.outcome === "restored" + ? "success" + : report.outcome === "healthy-flagged" || + report.outcome === "restore-skipped" + ? "warning" + : "danger"; + const marker = + report.outcome === "restored" + ? "✓" + : report.outcome === "healthy-flagged" || + report.outcome === "restore-skipped" + ? "!" + : "✗"; + logInfo( + `${deps.stylePromptText(marker, tone)} ${deps.stylePromptText(`${report.index + 1}. ${report.label}`, "accent")} ${deps.stylePromptText("|", "muted")} ${deps.styleAccountDetailText(report.message, tone)}`, + ); + } + logInfo(""); + logInfo( + deps.formatResultSummary([ + { + text: `${restored} restored`, + tone: restored > 0 ? "success" : "muted", + }, + { + text: `${healthyFlagged} healthy (kept flagged)`, + tone: healthyFlagged > 0 ? "warning" : "muted", + }, + { + text: `${stillFlagged} still flagged`, + tone: stillFlagged > 0 ? "danger" : "muted", + }, + ]), + ); + if (options.dryRun) { + logInfo( + deps.stylePromptText("Preview only: no changes were saved.", "warning"), + ); + } else if (!changed) { + logInfo(deps.stylePromptText("No storage changes were needed.", "muted")); + } + + return 0; +} diff --git a/test/codex-manager-verify-flagged-command.test.ts b/test/codex-manager-verify-flagged-command.test.ts new file mode 100644 index 00000000..f13a25cb --- /dev/null +++ b/test/codex-manager-verify-flagged-command.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from "vitest"; +import { + runVerifyFlaggedCommand, + type VerifyFlaggedCliOptions, + type VerifyFlaggedCommandDeps, +} from "../lib/codex-manager/commands/verify-flagged.js"; +import type { + AccountStorageV3, + FlaggedAccountMetadataV1, +} from "../lib/storage.js"; + +function createFlaggedAccount( + overrides: Partial = {}, +): FlaggedAccountMetadataV1 { + return { + email: "flagged@example.com", + refreshToken: "refresh-flagged", + addedAt: 1, + ...overrides, + }; +} + +function createStorage(): AccountStorageV3 { + return { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; +} + +function createDeps( + overrides: Partial = {}, +): VerifyFlaggedCommandDeps { + const parse = vi.fn((args: string[]) => { + if (args.includes("--bad")) + return { ok: false as const, message: "Unknown option: --bad" }; + return { + ok: true as const, + options: { + dryRun: false, + json: true, + restore: true, + } satisfies VerifyFlaggedCliOptions, + }; + }); + return { + setStoragePath: vi.fn(), + loadFlaggedAccounts: vi.fn(async () => ({ + version: 1 as const, + accounts: [createFlaggedAccount()], + })), + loadAccounts: vi.fn(async () => createStorage()), + queuedRefresh: vi.fn(async () => ({ + type: "failed", + reason: "invalid_grant", + message: "token expired", + })), + parseVerifyFlaggedArgs: parse, + printVerifyFlaggedUsage: vi.fn(), + createEmptyAccountStorage: vi.fn(() => createStorage()), + upsertRecoveredFlaggedAccount: vi.fn(() => ({ + restored: false, + changed: false, + message: "restore skipped", + })), + resolveStoredAccountIdentity: vi.fn(() => ({})), + extractAccountId: vi.fn(() => undefined), + extractAccountEmail: vi.fn(() => undefined), + sanitizeEmail: vi.fn((email) => email), + normalizeFailureDetail: vi.fn((message) => message ?? "unknown"), + withAccountAndFlaggedStorageTransaction: vi.fn(async (callback) => { + await callback(createStorage(), async () => undefined); + }), + normalizeDoctorIndexes: vi.fn(), + saveFlaggedAccounts: vi.fn(async () => undefined), + formatAccountLabel: vi.fn(() => "1. flagged@example.com"), + stylePromptText: vi.fn((text) => text), + styleAccountDetailText: vi.fn((text) => text), + formatResultSummary: vi.fn((segments) => + segments.map((segment) => segment.text).join(" | "), + ), + logInfo: vi.fn(), + logError: vi.fn(), + getNow: vi.fn(() => 1_000), + ...overrides, + }; +} + +describe("runVerifyFlaggedCommand", () => { + it("prints usage for help", async () => { + const deps = createDeps(); + const result = await runVerifyFlaggedCommand(["--help"], deps); + expect(result).toBe(0); + expect(deps.printVerifyFlaggedUsage).toHaveBeenCalled(); + }); + + it("rejects invalid options", async () => { + const deps = createDeps(); + const result = await runVerifyFlaggedCommand(["--bad"], deps); + expect(result).toBe(1); + expect(deps.logError).toHaveBeenCalledWith("Unknown option: --bad"); + }); + + it("emits json output for failed flagged refreshes", async () => { + const deps = createDeps(); + const result = await runVerifyFlaggedCommand([], deps); + expect(result).toBe(0); + expect(deps.logInfo).toHaveBeenCalledWith( + expect.stringContaining('"command": "verify-flagged"'), + ); + }); +}); From c88cd18dd01c2894ece3a6ee8e6a32e6c79b3cf0 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 04:55:25 +0800 Subject: [PATCH 032/376] refactor: route verify flagged through command module --- lib/codex-manager.ts | 71 +++++++++++++++++++++++++++----------------- 1 file changed, 44 insertions(+), 27 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 8e8d58e4..6a62ff17 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -2708,31 +2708,6 @@ function upsertRecoveredFlaggedAccount( }; } -async function runVerifyFlagged(args: string[]): Promise { - return runVerifyFlaggedCommand(args, { - setStoragePath, - loadFlaggedAccounts, - loadAccounts, - queuedRefresh, - parseVerifyFlaggedArgs, - printVerifyFlaggedUsage, - createEmptyAccountStorage, - upsertRecoveredFlaggedAccount, - resolveStoredAccountIdentity, - extractAccountId, - extractAccountEmail, - sanitizeEmail, - normalizeFailureDetail, - withAccountAndFlaggedStorageTransaction, - normalizeDoctorIndexes, - saveFlaggedAccounts, - formatAccountLabel, - stylePromptText, - styleAccountDetailText, - formatResultSummary, - }); -} - async function runFix(args: string[]): Promise { if (args.includes("--help") || args.includes("-h")) { printFixUsage(); @@ -3961,7 +3936,28 @@ async function runAuthLogin(args: string[]): Promise { "Problem Account Check", "Checking problem accounts", async () => { - await runVerifyFlagged([]); + await runVerifyFlaggedCommand([], { + setStoragePath, + loadFlaggedAccounts, + loadAccounts, + queuedRefresh, + parseVerifyFlaggedArgs, + printVerifyFlaggedUsage, + createEmptyAccountStorage, + upsertRecoveredFlaggedAccount, + resolveStoredAccountIdentity, + extractAccountId, + extractAccountEmail, + sanitizeEmail, + normalizeFailureDetail, + withAccountAndFlaggedStorageTransaction, + normalizeDoctorIndexes, + saveFlaggedAccounts, + formatAccountLabel, + stylePromptText, + styleAccountDetailText, + formatResultSummary, + }); }, displaySettings, ); @@ -4682,7 +4678,28 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { return runFeaturesCommand({ implementedFeatures: IMPLEMENTED_FEATURES }); } if (command === "verify-flagged") { - return runVerifyFlagged(rest); + return runVerifyFlaggedCommand(rest, { + setStoragePath, + loadFlaggedAccounts, + loadAccounts, + queuedRefresh, + parseVerifyFlaggedArgs, + printVerifyFlaggedUsage, + createEmptyAccountStorage, + upsertRecoveredFlaggedAccount, + resolveStoredAccountIdentity, + extractAccountId, + extractAccountEmail, + sanitizeEmail, + normalizeFailureDetail, + withAccountAndFlaggedStorageTransaction, + normalizeDoctorIndexes, + saveFlaggedAccounts, + formatAccountLabel, + stylePromptText, + styleAccountDetailText, + formatResultSummary, + }); } if (command === "forecast") { return runForecast(rest); From 70c3651abc6b5b072dd68cf440bda4cf11870383 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 04:56:11 +0800 Subject: [PATCH 033/376] test: harden wrapper version passthrough coverage --- test/codex-multi-auth-bin-wrapper.test.ts | 25 +++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/test/codex-multi-auth-bin-wrapper.test.ts b/test/codex-multi-auth-bin-wrapper.test.ts index 3dc039eb..ad0a8233 100644 --- a/test/codex-multi-auth-bin-wrapper.test.ts +++ b/test/codex-multi-auth-bin-wrapper.test.ts @@ -10,6 +10,7 @@ import { sleep } from "../lib/utils.js"; const createdDirs: string[] = []; const testFileDir = dirname(fileURLToPath(import.meta.url)); const repoRootDir = join(testFileDir, ".."); +const passthroughEnvKeys = ["HOME", "PATH", "SystemRoot", "TEMP", "TMP", "USERPROFILE"] as const; function isRetriableFsError(error: unknown): boolean { if (!error || typeof error !== "object" || !("code" in error)) { @@ -54,15 +55,24 @@ function createWrapperFixture(): string { return fixtureRoot; } +function createChildEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + for (const key of passthroughEnvKeys) { + const value = process.env[key]; + if (typeof value === "string" && value.length > 0) { + env[key] = value; + } + } + return env; +} + function runWrapper(fixtureRoot: string, args: string[] = []) { return spawnSync( process.execPath, [join(fixtureRoot, "scripts", "codex-multi-auth.js"), ...args], { encoding: "utf8", - env: { - ...process.env, - }, + env: createChildEnv(), }, ); } @@ -107,7 +117,10 @@ describe("codex-multi-auth bin wrapper", () => { expect(result.stderr).toContain("codex-multi-auth version is unavailable."); }); - it("passes multi-argument version flags through to the runtime", () => { + it.each([ + ["--version", "extra"], + ["-v", "extra"], + ])("passes multi-argument version flags through to the runtime: %s", (flag, extraArg) => { const fixtureRoot = createWrapperFixture(); const distLibDir = join(fixtureRoot, "dist", "lib"); mkdirSync(distLibDir, { recursive: true }); @@ -115,14 +128,14 @@ describe("codex-multi-auth bin wrapper", () => { join(distLibDir, "codex-manager.js"), [ "export async function runCodexMultiAuthCli(args) {", - '\tif (!Array.isArray(args) || args[0] !== "--version" || args[1] !== "extra") throw new Error("bad args");', + `\tif (!Array.isArray(args) || args[0] !== ${JSON.stringify(flag)} || args[1] !== ${JSON.stringify(extraArg)}) throw new Error("bad args");`, "\treturn 6;", "}", ].join("\n"), "utf8", ); - const result = runWrapper(fixtureRoot, ["--version", "extra"]); + const result = runWrapper(fixtureRoot, [flag, extraArg]); expect(result.status).toBe(6); expect(result.stdout).toBe(""); From 3a845ef7b6da23f6f8c04211c6d69a073d00fa8e Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 05:02:35 +0800 Subject: [PATCH 034/376] fix: make report output writes retry-safe --- lib/codex-manager/commands/report.ts | 34 +++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/lib/codex-manager/commands/report.ts b/lib/codex-manager/commands/report.ts index 388666c8..224a68cd 100644 --- a/lib/codex-manager/commands/report.ts +++ b/lib/codex-manager/commands/report.ts @@ -13,6 +13,7 @@ import { } from "../../quota-probe.js"; import type { AccountStorageV3 } from "../../storage.js"; import type { TokenFailure, TokenResult } from "../../types.js"; +import { sleep } from "../../utils.js"; interface ReportCliOptions { live: boolean; @@ -25,6 +26,8 @@ type ParsedArgsResult = | { ok: true; options: T } | { ok: false; message: string }; +const RETRYABLE_WRITE_CODES = new Set(["EBUSY", "EPERM"]); + export interface ReportCommandDeps { setStoragePath: (path: string | null) => void; getStoragePath: () => string; @@ -52,6 +55,11 @@ export interface ReportCommandDeps { writeFile?: (path: string, contents: string) => Promise; } +function isRetryableWriteError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return typeof code === "string" && RETRYABLE_WRITE_CODES.has(code); +} + function printReportUsage(logInfo: (message: string) => void): void { logInfo( [ @@ -165,7 +173,31 @@ function serializeForecastResults( async function defaultWriteFile(path: string, contents: string): Promise { await fs.mkdir(dirname(path), { recursive: true }); - await fs.writeFile(path, contents, "utf-8"); + const tempPath = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; + await fs.writeFile(tempPath, contents, "utf-8"); + let moved = false; + try { + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await fs.rename(tempPath, path); + moved = true; + return; + } catch (error) { + if (!isRetryableWriteError(error) || attempt >= 4) { + throw error; + } + await sleep(10 * 2 ** attempt); + } + } + } finally { + if (!moved) { + try { + await fs.unlink(tempPath); + } catch { + // Best-effort temp cleanup. + } + } + } } export async function runReportCommand( From 3aa75203069fad99587c867f92983170923d7330 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 05:02:35 +0800 Subject: [PATCH 035/376] test: cover disabled rate-limited status output --- test/codex-manager-cli.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 6a6a824d..24200c28 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -633,9 +633,7 @@ describe("codex manager cli commands", () => { addedAt: now - 2_000, lastUsed: now - 500, enabled: false, - rateLimits: { - codex_rpm: { remaining: 0, resetAt: now + 60_000 }, - }, + rateLimitResetTimes: { codex: now + 60_000 }, }, ], }); @@ -650,7 +648,7 @@ describe("codex manager cli commands", () => { expect.stringContaining("1. 1. active@example.com [current]"), ); expect(logSpy).toHaveBeenCalledWith( - expect.stringContaining("2. 2. disabled@example.com [disabled]"), + expect.stringContaining("2. 2. disabled@example.com [disabled, rate-limited]"), ); }); From 170c9591d1b5bb0e54aa03cfaf4cea965ff0d7a2 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 05:02:35 +0800 Subject: [PATCH 036/376] docs: remove duplicate release index row --- docs/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/README.md b/docs/README.md index 6aba682f..43fd89c6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,7 +29,6 @@ Public documentation for `codex-multi-auth`. | [releases/v1.1.10.md](releases/v1.1.10.md) | Stable release notes | | [releases/v0.1.9.md](releases/v0.1.9.md) | Previous stable release notes | | [releases/v0.1.7.md](releases/v0.1.7.md) | Earlier stable release notes | -| [releases/v0.1.7.md](releases/v0.1.7.md) | Archived stable release notes | | [releases/v0.1.6.md](releases/v0.1.6.md) | Archived stable release notes | | [releases/v0.1.5.md](releases/v0.1.5.md) | Archived stable release notes | | [releases/v0.1.0-beta.0.md](releases/v0.1.0-beta.0.md) | Archived prerelease notes | From 30854b72d48cdf6ac026934d9a12703d98f10dee Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 05:02:57 +0800 Subject: [PATCH 037/376] refactor: extract best command --- lib/codex-manager.ts | 302 ++-------------------- lib/codex-manager/commands/best.ts | 329 ++++++++++++++++++++++++ test/codex-manager-best-command.test.ts | 120 +++++++++ 3 files changed, 472 insertions(+), 279 deletions(-) create mode 100644 lib/codex-manager/commands/best.ts create mode 100644 test/codex-manager-best-command.test.ts diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 6a62ff17..c54fda9b 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -35,6 +35,10 @@ import { loadCodexCliState, } from "./codex-cli/state.js"; import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; +import { + type BestCliOptions, + runBestCommand, +} from "./codex-manager/commands/best.js"; import { runCheckCommand } from "./codex-manager/commands/check.js"; import { runForecastCommand } from "./codex-manager/commands/forecast.js"; import { runReportCommand } from "./codex-manager/commands/report.js"; @@ -2247,13 +2251,6 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { ); } -interface BestCliOptions { - live: boolean; - json: boolean; - model: string; - modelProvided: boolean; -} - interface FixCliOptions { dryRun: boolean; json: boolean; @@ -4282,279 +4279,26 @@ async function persistAndSyncSelectedAccount({ } async function runBest(args: string[]): Promise { - if (args.includes("--help") || args.includes("-h")) { - printBestUsage(); - return 0; - } - - const parsedArgs = parseBestArgs(args); - if (!parsedArgs.ok) { - console.error(parsedArgs.message); - printBestUsage(); - return 1; - } - const options = parsedArgs.options; - if (options.modelProvided && !options.live) { - console.error("--model requires --live for codex auth best"); - printBestUsage(); - return 1; - } - - setStoragePath(null); - const storage = await loadAccounts(); - if (!storage || storage.accounts.length === 0) { - if (options.json) { - console.log( - JSON.stringify({ error: "No accounts configured." }, null, 2), - ); - } else { - console.log("No accounts configured."); - } - return 1; - } - - const now = Date.now(); - const refreshFailures = new Map(); - const liveQuotaByIndex = new Map< - number, - Awaited> - >(); - const probeIdTokenByIndex = new Map(); - const probeRefreshedIndices = new Set(); - const probeErrors: string[] = []; - let changed = false; - - const printProbeNotes = (): void => { - if (probeErrors.length === 0) return; - console.log(`Live check notes (${probeErrors.length}):`); - for (const error of probeErrors) { - console.log(` - ${error}`); - } - }; - - const persistProbeChangesIfNeeded = async (): Promise => { - if (!changed) return; - await saveAccounts(storage); - changed = false; - }; - - for (let i = 0; i < storage.accounts.length; i += 1) { - const account = storage.accounts[i]; - if (!account || !options.live) continue; - if (account.enabled === false) continue; - - let probeAccessToken = account.accessToken; - let probeAccountId = - account.accountId ?? extractAccountId(account.accessToken); - if (!hasUsableAccessToken(account, now)) { - const refreshResult = await queuedRefresh(account.refreshToken); - if (refreshResult.type !== "success") { - refreshFailures.set(i, { - ...refreshResult, - message: normalizeFailureDetail( - refreshResult.message, - refreshResult.reason, - ), - }); - continue; - } - - const refreshedEmail = sanitizeEmail( - extractAccountEmail(refreshResult.access, refreshResult.idToken), - ); - const refreshedAccountId = extractAccountId(refreshResult.access); - - if (account.refreshToken !== refreshResult.refresh) { - account.refreshToken = refreshResult.refresh; - changed = true; - } - if (account.accessToken !== refreshResult.access) { - account.accessToken = refreshResult.access; - changed = true; - } - if (account.expiresAt !== refreshResult.expires) { - account.expiresAt = refreshResult.expires; - changed = true; - } - if (refreshedEmail && refreshedEmail !== account.email) { - account.email = refreshedEmail; - changed = true; - } - if (refreshedAccountId && refreshedAccountId !== account.accountId) { - account.accountId = refreshedAccountId; - account.accountIdSource = "token"; - changed = true; - } - if (refreshResult.idToken) { - probeIdTokenByIndex.set(i, refreshResult.idToken); - } - probeRefreshedIndices.add(i); - - probeAccessToken = account.accessToken; - probeAccountId = account.accountId ?? refreshedAccountId; - } - - if (!probeAccessToken || !probeAccountId) { - probeErrors.push( - `${formatAccountLabel(account, i)}: missing accountId for live probe`, - ); - continue; - } - - try { - const liveQuota = await fetchCodexQuotaSnapshot({ - accountId: probeAccountId, - accessToken: probeAccessToken, - model: options.model, - }); - liveQuotaByIndex.set(i, liveQuota); - } catch (error) { - const message = normalizeFailureDetail( - error instanceof Error ? error.message : String(error), - undefined, - ); - probeErrors.push(`${formatAccountLabel(account, i)}: ${message}`); - } - } - - const forecastInputs = storage.accounts.map((account, index) => ({ - index, - account, - isCurrent: index === resolveActiveIndex(storage, "codex"), - now, - refreshFailure: refreshFailures.get(index), - liveQuota: liveQuotaByIndex.get(index), - })); - - const forecastResults = evaluateForecastAccounts(forecastInputs); - const recommendation = recommendForecastAccount(forecastResults); - - if (recommendation.recommendedIndex === null) { - await persistProbeChangesIfNeeded(); - if (options.json) { - console.log( - JSON.stringify( - { - error: recommendation.reason, - ...(probeErrors.length > 0 ? { probeErrors } : {}), - }, - null, - 2, - ), - ); - } else { - console.log(`No best account available: ${recommendation.reason}`); - printProbeNotes(); - } - return 1; - } - - const bestIndex = recommendation.recommendedIndex; - const bestAccount = storage.accounts[bestIndex]; - if (!bestAccount) { - await persistProbeChangesIfNeeded(); - if (options.json) { - console.log( - JSON.stringify({ error: "Best account not found." }, null, 2), - ); - } else { - console.log("Best account not found."); - } - return 1; - } - - // Check if already on best account - const currentIndex = resolveActiveIndex(storage, "codex"); - if (currentIndex === bestIndex) { - const shouldSyncCurrentBest = - probeRefreshedIndices.has(bestIndex) || - probeIdTokenByIndex.has(bestIndex); - let alreadyBestSynced: boolean | undefined; - if (changed) { - bestAccount.lastUsed = now; - await persistProbeChangesIfNeeded(); - } - if (shouldSyncCurrentBest) { - alreadyBestSynced = await setCodexCliActiveSelection({ - accountId: bestAccount.accountId, - email: bestAccount.email, - accessToken: bestAccount.accessToken, - refreshToken: bestAccount.refreshToken, - expiresAt: bestAccount.expiresAt, - ...(probeIdTokenByIndex.has(bestIndex) - ? { idToken: probeIdTokenByIndex.get(bestIndex) } - : {}), - }); - if (!alreadyBestSynced && !options.json) { - console.warn( - "Codex auth sync did not complete. Multi-auth routing will still use this account.", - ); - } - } - if (options.json) { - console.log( - JSON.stringify( - { - message: `Already on best account: ${formatAccountLabel(bestAccount, bestIndex)}`, - accountIndex: bestIndex + 1, - reason: recommendation.reason, - ...(alreadyBestSynced !== undefined - ? { synced: alreadyBestSynced } - : {}), - ...(probeErrors.length > 0 ? { probeErrors } : {}), - }, - null, - 2, - ), - ); - } else { - console.log( - `Already on best account ${bestIndex + 1}: ${formatAccountLabel(bestAccount, bestIndex)}`, - ); - console.log(`Reason: ${recommendation.reason}`); - printProbeNotes(); - } - return 0; - } - - const targetIndex = bestIndex; - const parsed = targetIndex + 1; - const { synced, wasDisabled } = await persistAndSyncSelectedAccount({ - storage, - targetIndex, - parsed, - switchReason: "best", - initialSyncIdToken: probeIdTokenByIndex.get(bestIndex), + return runBestCommand(args, { + setStoragePath, + loadAccounts, + saveAccounts, + parseBestArgs, + printBestUsage, + resolveActiveIndex, + hasUsableAccessToken, + queuedRefresh, + normalizeFailureDetail, + extractAccountId, + extractAccountEmail, + sanitizeEmail, + formatAccountLabel, + fetchCodexQuotaSnapshot, + evaluateForecastAccounts, + recommendForecastAccount, + persistAndSyncSelectedAccount, + setCodexCliActiveSelection, }); - - if (options.json) { - console.log( - JSON.stringify( - { - message: `Switched to best account: ${formatAccountLabel(bestAccount, targetIndex)}`, - accountIndex: parsed, - reason: recommendation.reason, - synced, - wasDisabled, - ...(probeErrors.length > 0 ? { probeErrors } : {}), - }, - null, - 2, - ), - ); - } else { - console.log( - `Switched to best account ${parsed}: ${formatAccountLabel(bestAccount, targetIndex)}${wasDisabled ? " (re-enabled)" : ""}`, - ); - console.log(`Reason: ${recommendation.reason}`); - printProbeNotes(); - if (!synced) { - console.warn( - "Codex auth sync did not complete. Multi-auth routing will still use this account.", - ); - } - } - return 0; } export async function autoSyncActiveAccountToCodex(): Promise { diff --git a/lib/codex-manager/commands/best.ts b/lib/codex-manager/commands/best.ts new file mode 100644 index 00000000..d560e492 --- /dev/null +++ b/lib/codex-manager/commands/best.ts @@ -0,0 +1,329 @@ +import type { ForecastAccountResult } from "../../forecast.js"; +import type { CodexQuotaSnapshot } from "../../quota-probe.js"; +import type { AccountStorageV3 } from "../../storage.js"; +import type { TokenFailure, TokenResult } from "../../types.js"; + +export interface BestCliOptions { + live: boolean; + json: boolean; + model: string; + modelProvided: boolean; +} + +type ParsedArgsResult = + | { ok: true; options: T } + | { ok: false; message: string }; + +export interface BestCommandDeps { + setStoragePath: (path: string | null) => void; + loadAccounts: () => Promise; + saveAccounts: (storage: AccountStorageV3) => Promise; + parseBestArgs: (args: string[]) => ParsedArgsResult; + printBestUsage: () => void; + resolveActiveIndex: (storage: AccountStorageV3, family?: "codex") => number; + hasUsableAccessToken: ( + account: { accessToken?: string; expiresAt?: number }, + now: number, + ) => boolean; + queuedRefresh: (refreshToken: string) => Promise; + normalizeFailureDetail: ( + message: string | undefined, + reason: string | undefined, + ) => string; + extractAccountId: (accessToken: string | undefined) => string | undefined; + extractAccountEmail: ( + accessToken: string | undefined, + idToken: string | undefined, + ) => string | undefined; + sanitizeEmail: (email: string | undefined) => string | undefined; + formatAccountLabel: ( + account: { email?: string; accountLabel?: string; accountId?: string }, + index: number, + ) => string; + fetchCodexQuotaSnapshot: (input: { + accountId: string; + accessToken: string; + model: string; + }) => Promise; + evaluateForecastAccounts: ( + inputs: Array<{ + index: number; + account: AccountStorageV3["accounts"][number]; + isCurrent: boolean; + now: number; + refreshFailure?: TokenFailure; + liveQuota?: CodexQuotaSnapshot; + }>, + ) => ForecastAccountResult[]; + recommendForecastAccount: (results: ForecastAccountResult[]) => { + recommendedIndex: number | null; + reason: string; + }; + persistAndSyncSelectedAccount: (params: { + storage: AccountStorageV3; + targetIndex: number; + parsed: number; + switchReason: "best"; + initialSyncIdToken?: string; + }) => Promise<{ synced: boolean; wasDisabled: boolean }>; + setCodexCliActiveSelection: (params: { + accountId?: string; + email?: string; + accessToken?: string; + refreshToken: string; + expiresAt?: number; + idToken?: string; + }) => Promise; + logInfo?: (message: string) => void; + logWarn?: (message: string) => void; + logError?: (message: string) => void; + getNow?: () => number; +} + +export async function runBestCommand( + args: string[], + deps: BestCommandDeps, +): Promise { + const logInfo = deps.logInfo ?? console.log; + const logWarn = deps.logWarn ?? console.warn; + const logError = deps.logError ?? console.error; + if (args.includes("--help") || args.includes("-h")) { + deps.printBestUsage(); + return 0; + } + + const parsedArgs = deps.parseBestArgs(args); + if (!parsedArgs.ok) { + logError(parsedArgs.message); + deps.printBestUsage(); + return 1; + } + const options = parsedArgs.options; + if (options.modelProvided && !options.live) { + logError("--model requires --live for codex auth best"); + deps.printBestUsage(); + return 1; + } + + deps.setStoragePath(null); + const storage = await deps.loadAccounts(); + if (!storage || storage.accounts.length === 0) { + if (options.json) { + logInfo(JSON.stringify({ error: "No accounts configured." }, null, 2)); + } else { + logInfo("No accounts configured."); + } + return 1; + } + + const now = deps.getNow?.() ?? Date.now(); + const refreshFailures = new Map(); + const liveQuotaByIndex = new Map(); + const probeIdTokenByIndex = new Map(); + const probeRefreshedIndices = new Set(); + const probeErrors: string[] = []; + let changed = false; + + for (let i = 0; i < storage.accounts.length; i += 1) { + const account = storage.accounts[i]; + if (!account || !options.live || account.enabled === false) continue; + + let probeAccessToken = account.accessToken; + let probeAccountId = + account.accountId ?? deps.extractAccountId(account.accessToken); + if (!deps.hasUsableAccessToken(account, now)) { + const refreshResult = await deps.queuedRefresh(account.refreshToken); + if (refreshResult.type !== "success") { + refreshFailures.set(i, { + ...refreshResult, + message: deps.normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ), + }); + continue; + } + + const refreshedEmail = deps.sanitizeEmail( + deps.extractAccountEmail(refreshResult.access, refreshResult.idToken), + ); + const refreshedAccountId = deps.extractAccountId(refreshResult.access); + account.refreshToken = refreshResult.refresh; + account.accessToken = refreshResult.access; + account.expiresAt = refreshResult.expires; + if (refreshedEmail) account.email = refreshedEmail; + changed = true; + if (refreshedAccountId) { + account.accountId = refreshedAccountId; + account.accountIdSource = "token"; + } + if (refreshResult.idToken) + probeIdTokenByIndex.set(i, refreshResult.idToken); + probeRefreshedIndices.add(i); + + probeAccessToken = account.accessToken; + probeAccountId = account.accountId ?? refreshedAccountId; + } + + if (!probeAccessToken || !probeAccountId) { + probeErrors.push( + `${deps.formatAccountLabel(account, i)}: missing accountId for live probe`, + ); + continue; + } + + try { + const liveQuota = await deps.fetchCodexQuotaSnapshot({ + accountId: probeAccountId, + accessToken: probeAccessToken, + model: options.model, + }); + liveQuotaByIndex.set(i, liveQuota); + } catch (error) { + const message = deps.normalizeFailureDetail( + error instanceof Error ? error.message : String(error), + undefined, + ); + probeErrors.push(`${deps.formatAccountLabel(account, i)}: ${message}`); + } + } + + const forecastInputs = storage.accounts.map((account, index) => ({ + index, + account, + isCurrent: index === deps.resolveActiveIndex(storage, "codex"), + now, + refreshFailure: refreshFailures.get(index), + liveQuota: liveQuotaByIndex.get(index), + })); + const forecastResults = deps.evaluateForecastAccounts(forecastInputs); + const recommendation = deps.recommendForecastAccount(forecastResults); + + const printProbeNotes = (): void => { + if (probeErrors.length === 0) return; + logInfo(`Live check notes (${probeErrors.length}):`); + for (const error of probeErrors) logInfo(` - ${error}`); + }; + + if (recommendation.recommendedIndex === null) { + if (options.json) { + logInfo( + JSON.stringify( + { + error: recommendation.reason, + ...(probeErrors.length > 0 ? { probeErrors } : {}), + }, + null, + 2, + ), + ); + } else { + logInfo(`No best account available: ${recommendation.reason}`); + printProbeNotes(); + } + return 1; + } + + const bestIndex = recommendation.recommendedIndex; + const bestAccount = storage.accounts[bestIndex]; + if (!bestAccount) { + if (options.json) { + logInfo(JSON.stringify({ error: "Best account not found." }, null, 2)); + } else { + logInfo("Best account not found."); + } + return 1; + } + + const currentIndex = deps.resolveActiveIndex(storage, "codex"); + if (currentIndex === bestIndex) { + const shouldSyncCurrentBest = + probeRefreshedIndices.has(bestIndex) || + probeIdTokenByIndex.has(bestIndex); + let alreadyBestSynced: boolean | undefined; + if (shouldSyncCurrentBest) { + if (changed) { + bestAccount.lastUsed = now; + await deps.saveAccounts(storage); + changed = false; + } + alreadyBestSynced = await deps.setCodexCliActiveSelection({ + accountId: bestAccount.accountId, + email: bestAccount.email, + accessToken: bestAccount.accessToken, + refreshToken: bestAccount.refreshToken, + expiresAt: bestAccount.expiresAt, + ...(probeIdTokenByIndex.has(bestIndex) + ? { idToken: probeIdTokenByIndex.get(bestIndex) } + : {}), + }); + if (!alreadyBestSynced && !options.json) { + logWarn( + "Codex auth sync did not complete. Multi-auth routing will still use this account.", + ); + } + } + if (options.json) { + logInfo( + JSON.stringify( + { + message: `Already on best account: ${deps.formatAccountLabel(bestAccount, bestIndex)}`, + accountIndex: bestIndex + 1, + reason: recommendation.reason, + ...(alreadyBestSynced !== undefined + ? { synced: alreadyBestSynced } + : {}), + ...(probeErrors.length > 0 ? { probeErrors } : {}), + }, + null, + 2, + ), + ); + } else { + logInfo( + `Already on best account ${bestIndex + 1}: ${deps.formatAccountLabel(bestAccount, bestIndex)}`, + ); + logInfo(`Reason: ${recommendation.reason}`); + printProbeNotes(); + } + return 0; + } + + const parsed = bestIndex + 1; + const { synced, wasDisabled } = await deps.persistAndSyncSelectedAccount({ + storage, + targetIndex: bestIndex, + parsed, + switchReason: "best", + initialSyncIdToken: probeIdTokenByIndex.get(bestIndex), + }); + + if (options.json) { + logInfo( + JSON.stringify( + { + message: `Switched to best account: ${deps.formatAccountLabel(bestAccount, bestIndex)}`, + accountIndex: parsed, + reason: recommendation.reason, + synced, + wasDisabled, + ...(probeErrors.length > 0 ? { probeErrors } : {}), + }, + null, + 2, + ), + ); + } else { + logInfo( + `Switched to best account ${parsed}: ${deps.formatAccountLabel(bestAccount, bestIndex)}${wasDisabled ? " (re-enabled)" : ""}`, + ); + logInfo(`Reason: ${recommendation.reason}`); + printProbeNotes(); + if (!synced) { + logWarn( + "Codex auth sync did not complete. Multi-auth routing will still use this account.", + ); + } + } + return 0; +} diff --git a/test/codex-manager-best-command.test.ts b/test/codex-manager-best-command.test.ts new file mode 100644 index 00000000..edc1bc1f --- /dev/null +++ b/test/codex-manager-best-command.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from "vitest"; +import { + type BestCliOptions, + type BestCommandDeps, + runBestCommand, +} from "../lib/codex-manager/commands/best.js"; +import type { AccountStorageV3 } from "../lib/storage.js"; + +function createStorage(): AccountStorageV3 { + return { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "best@example.com", + refreshToken: "refresh-best", + accessToken: "access-best", + expiresAt: Date.now() + 60_000, + addedAt: 1, + lastUsed: 1, + enabled: true, + }, + ], + }; +} + +function createDeps(overrides: Partial = {}): BestCommandDeps { + return { + setStoragePath: vi.fn(), + loadAccounts: vi.fn(async () => createStorage()), + saveAccounts: vi.fn(async () => undefined), + parseBestArgs: vi.fn((args: string[]) => { + if (args.includes("--bad")) + return { ok: false as const, message: "Unknown option: --bad" }; + return { + ok: true as const, + options: { + live: false, + json: true, + model: "gpt-5-codex", + modelProvided: false, + } satisfies BestCliOptions, + }; + }), + printBestUsage: vi.fn(), + resolveActiveIndex: vi.fn(() => 0), + hasUsableAccessToken: vi.fn(() => true), + queuedRefresh: vi.fn(async () => ({ + type: "success", + access: "access-best", + refresh: "refresh-best", + expires: Date.now() + 60_000, + })), + normalizeFailureDetail: vi.fn((message) => message ?? "unknown"), + extractAccountId: vi.fn(() => "account-id"), + extractAccountEmail: vi.fn(() => "best@example.com"), + sanitizeEmail: vi.fn((email) => email), + formatAccountLabel: vi.fn( + (_account, index) => `${index + 1}. best@example.com`, + ), + fetchCodexQuotaSnapshot: vi.fn(async () => ({ + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + })), + evaluateForecastAccounts: vi.fn(() => [ + { + index: 0, + label: "1. best@example.com", + isCurrent: true, + availability: "ready", + riskScore: 0, + riskLevel: "low", + waitMs: 0, + reasons: ["healthy"], + }, + ]), + recommendForecastAccount: vi.fn(() => ({ + recommendedIndex: 0, + reason: "lowest risk", + })), + persistAndSyncSelectedAccount: vi.fn(async () => ({ + synced: true, + wasDisabled: false, + })), + setCodexCliActiveSelection: vi.fn(async () => true), + logInfo: vi.fn(), + logWarn: vi.fn(), + logError: vi.fn(), + getNow: vi.fn(() => 1_000), + ...overrides, + }; +} + +describe("runBestCommand", () => { + it("prints usage for help", async () => { + const deps = createDeps(); + const result = await runBestCommand(["--help"], deps); + expect(result).toBe(0); + expect(deps.printBestUsage).toHaveBeenCalled(); + }); + + it("rejects invalid options", async () => { + const deps = createDeps(); + const result = await runBestCommand(["--bad"], deps); + expect(result).toBe(1); + expect(deps.logError).toHaveBeenCalledWith("Unknown option: --bad"); + }); + + it("prints json output when already on the best account", async () => { + const deps = createDeps(); + const result = await runBestCommand([], deps); + expect(result).toBe(0); + expect(deps.logInfo).toHaveBeenCalledWith( + expect.stringContaining('"accountIndex": 1'), + ); + }); +}); From 26f7219e50cb81422ca2f0edf6548c5525748423 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 05:05:54 +0800 Subject: [PATCH 038/376] fix: isolate forecast formatter state --- lib/codex-manager/commands/forecast.ts | 17 ++++-- test/codex-manager-forecast-command.test.ts | 57 +++++++++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/lib/codex-manager/commands/forecast.ts b/lib/codex-manager/commands/forecast.ts index 5793285c..2d33a1a0 100644 --- a/lib/codex-manager/commands/forecast.ts +++ b/lib/codex-manager/commands/forecast.ts @@ -97,6 +97,14 @@ export interface ForecastCommandDeps { getNow?: () => number; } +function joinStyledSegments( + parts: string[], + styleText: (text: string, tone: PromptTone) => string, +): string { + if (parts.length === 0) return ""; + return parts.join(styleText(" | ", "muted")); +} + function printForecastUsage(logInfo: (message: string) => void): void { logInfo( [ @@ -154,6 +162,7 @@ function serializeForecastResults( results: ForecastAccountResult[], liveQuotaByIndex: Map, refreshFailures: Map, + formatQuotaSnapshotLine: (snapshot: CodexQuotaSnapshot) => string, ): Array<{ index: number; label: string; @@ -189,7 +198,7 @@ function serializeForecastResults( planType: liveQuota.planType, activeLimit: liveQuota.activeLimit, model: liveQuota.model, - summary: deps_formatQuotaSnapshotLine(liveQuota), + summary: formatQuotaSnapshotLine(liveQuota), } : undefined, refreshFailure: refreshFailures.get(result.index), @@ -197,15 +206,12 @@ function serializeForecastResults( }); } -let deps_formatQuotaSnapshotLine: (snapshot: CodexQuotaSnapshot) => string; - export async function runForecastCommand( args: string[], deps: ForecastCommandDeps & { formatQuotaSnapshotLine: (snapshot: CodexQuotaSnapshot) => string; }, ): Promise { - deps_formatQuotaSnapshotLine = deps.formatQuotaSnapshotLine; const logInfo = deps.logInfo ?? console.log; const logError = deps.logError ?? console.error; if (args.includes("--help") || args.includes("-h")) { @@ -334,6 +340,7 @@ export async function runForecastCommand( forecastResults, liveQuotaByIndex, refreshFailures, + deps.formatQuotaSnapshotLine, ), }, null, @@ -391,7 +398,7 @@ export async function runForecastCommand( const rowParts = [availabilityLabel, riskLabel]; if (waitLabel) rowParts.push(waitLabel); logInfo( - `${indexLabel} ${accountLabel} ${deps.stylePromptText("|", "muted")} ${rowParts.join(deps.stylePromptText(" | ", "muted"))}`, + `${indexLabel} ${accountLabel} ${deps.stylePromptText("|", "muted")} ${joinStyledSegments(rowParts, deps.stylePromptText)}`, ); if (display.showForecastReasons && result.reasons.length > 0) { logInfo( diff --git a/test/codex-manager-forecast-command.test.ts b/test/codex-manager-forecast-command.test.ts index 6d0d5128..ee1bb62e 100644 --- a/test/codex-manager-forecast-command.test.ts +++ b/test/codex-manager-forecast-command.test.ts @@ -139,4 +139,61 @@ describe("runForecastCommand", () => { expect.stringContaining('"command": "forecast"'), ); }); + + it("keeps concurrent json runs bound to their own quota formatter", async () => { + let releaseSlowLoad: (() => void) | undefined; + const slowLoad = new Promise((resolve) => { + releaseSlowLoad = resolve; + }); + const slowDeps = createDeps({ + loadAccounts: vi.fn(async () => { + await slowLoad; + return createStorage(); + }), + formatQuotaSnapshotLine: vi.fn(() => "slow quota"), + fetchCodexQuotaSnapshot: vi.fn(async () => ({ + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + })), + }); + const fastDeps = createDeps({ + formatQuotaSnapshotLine: vi.fn(() => "fast quota"), + fetchCodexQuotaSnapshot: vi.fn(async () => ({ + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + })), + }); + + const slowRun = runForecastCommand(["--json", "--live"], slowDeps); + const fastRun = runForecastCommand(["--json", "--live"], fastDeps); + releaseSlowLoad?.(); + + const [slowResult, fastResult] = await Promise.all([slowRun, fastRun]); + + expect(slowResult).toBe(0); + expect(fastResult).toBe(0); + expect(slowDeps.logInfo).toHaveBeenCalledWith( + expect.stringContaining('"summary": "slow quota"'), + ); + expect(fastDeps.logInfo).toHaveBeenCalledWith( + expect.stringContaining('"summary": "fast quota"'), + ); + }); + + it("keeps muted separators between styled forecast row segments", async () => { + const deps = createDeps({ + stylePromptText: vi.fn((text, tone) => `<${tone}>${text}`), + }); + + const result = await runForecastCommand([], deps); + + expect(result).toBe(0); + expect(deps.logInfo).toHaveBeenCalledWith( + '1. 1. forecast@example.com [current] | ready | low risk (0)', + ); + }); }); From 81fd6167d37b6c4575b1c90ae636595e601c31dc Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 05:12:46 +0800 Subject: [PATCH 039/376] refactor: extract doctor command --- lib/codex-manager.ts | 527 ++-------------------- lib/codex-manager/commands/doctor.ts | 470 +++++++++++++++++++ test/codex-manager-doctor-command.test.ts | 94 ++++ 3 files changed, 593 insertions(+), 498 deletions(-) create mode 100644 lib/codex-manager/commands/doctor.ts create mode 100644 test/codex-manager-doctor-command.test.ts diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index c54fda9b..ac33877b 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -1,4 +1,3 @@ -import { existsSync, promises as fs } from "node:fs"; import { stdin as input, stdout as output } from "node:process"; import { createInterface } from "node:readline/promises"; import { @@ -40,6 +39,10 @@ import { runBestCommand, } from "./codex-manager/commands/best.js"; import { runCheckCommand } from "./codex-manager/commands/check.js"; +import { + type DoctorCliOptions, + runDoctorCommand, +} from "./codex-manager/commands/doctor.js"; import { runForecastCommand } from "./codex-manager/commands/forecast.js"; import { runReportCommand } from "./codex-manager/commands/report.js"; import { @@ -2436,12 +2439,6 @@ function parseVerifyFlaggedArgs( return { ok: true, options }; } -interface DoctorCliOptions { - json: boolean; - fix: boolean; - dryRun: boolean; -} - function printDoctorUsage(): void { console.log( [ @@ -3103,15 +3100,6 @@ async function runFix(args: string[]): Promise { return 0; } -type DoctorSeverity = "ok" | "warn" | "error"; - -interface DoctorCheck { - key: string; - severity: DoctorSeverity; - message: string; - details?: string; -} - interface DoctorFixAction { key: string; message: string; @@ -3245,489 +3233,32 @@ function applyDoctorFixes(storage: AccountStorageV3): { } async function runDoctor(args: string[]): Promise { - if (args.includes("--help") || args.includes("-h")) { - printDoctorUsage(); - return 0; - } - - const parsedArgs = parseDoctorArgs(args); - if (!parsedArgs.ok) { - console.error(parsedArgs.message); - printDoctorUsage(); - return 1; - } - const options = parsedArgs.options; - - setStoragePath(null); - const storagePath = getStoragePath(); - const checks: DoctorCheck[] = []; - const addCheck = (check: DoctorCheck): void => { - checks.push(check); - }; - - addCheck({ - key: "storage-file", - severity: existsSync(storagePath) ? "ok" : "warn", - message: existsSync(storagePath) - ? "Account storage file found" - : "Account storage file does not exist yet (first login pending)", - details: storagePath, - }); - - if (existsSync(storagePath)) { - try { - const stat = await fs.stat(storagePath); - addCheck({ - key: "storage-readable", - severity: stat.size > 0 ? "ok" : "warn", - message: - stat.size > 0 ? "Storage file is readable" : "Storage file is empty", - details: `${stat.size} bytes`, - }); - } catch (error) { - addCheck({ - key: "storage-readable", - severity: "error", - message: "Unable to read storage file metadata", - details: error instanceof Error ? error.message : String(error), - }); - } - } - - const codexAuthPath = getCodexCliAuthPath(); - const codexConfigPath = getCodexCliConfigPath(); - let codexAuthEmail: string | undefined; - let codexAuthAccountId: string | undefined; - - addCheck({ - key: "codex-auth-file", - severity: existsSync(codexAuthPath) ? "ok" : "warn", - message: existsSync(codexAuthPath) - ? "Codex auth file found" - : "Codex auth file does not exist", - details: codexAuthPath, - }); - - if (existsSync(codexAuthPath)) { - try { - const raw = await fs.readFile(codexAuthPath, "utf-8"); - const parsed = JSON.parse(raw) as unknown; - if (parsed && typeof parsed === "object") { - const payload = parsed as Record; - const tokens = - payload.tokens && typeof payload.tokens === "object" - ? (payload.tokens as Record) - : null; - const accessToken = - tokens && typeof tokens.access_token === "string" - ? tokens.access_token - : undefined; - const idToken = - tokens && typeof tokens.id_token === "string" - ? tokens.id_token - : undefined; - const accountIdFromFile = - tokens && typeof tokens.account_id === "string" - ? tokens.account_id - : undefined; - const emailFromFile = - typeof payload.email === "string" ? payload.email : undefined; - codexAuthEmail = sanitizeEmail( - emailFromFile ?? extractAccountEmail(accessToken, idToken), - ); - codexAuthAccountId = accountIdFromFile ?? extractAccountId(accessToken); - } - addCheck({ - key: "codex-auth-readable", - severity: "ok", - message: "Codex auth file is readable", - details: - codexAuthEmail || codexAuthAccountId - ? `email=${codexAuthEmail ?? "unknown"}, accountId=${codexAuthAccountId ?? "unknown"}` - : undefined, - }); - } catch (error) { - addCheck({ - key: "codex-auth-readable", - severity: "error", - message: "Unable to read Codex auth file", - details: error instanceof Error ? error.message : String(error), - }); - } - } - - addCheck({ - key: "codex-config-file", - severity: existsSync(codexConfigPath) ? "ok" : "warn", - message: existsSync(codexConfigPath) - ? "Codex config file found" - : "Codex config file does not exist", - details: codexConfigPath, - }); - - let codexAuthStoreMode: string | undefined; - if (existsSync(codexConfigPath)) { - try { - const configRaw = await fs.readFile(codexConfigPath, "utf-8"); - const match = configRaw.match( - /^\s*cli_auth_credentials_store\s*=\s*"([^"]+)"\s*$/m, - ); - if (match?.[1]) { - codexAuthStoreMode = match[1].trim(); - } - } catch (error) { - addCheck({ - key: "codex-auth-store", - severity: "warn", - message: "Unable to read Codex auth-store config", - details: error instanceof Error ? error.message : String(error), - }); - } - } - if (!checks.some((check) => check.key === "codex-auth-store")) { - addCheck({ - key: "codex-auth-store", - severity: codexAuthStoreMode === "file" ? "ok" : "warn", - message: - codexAuthStoreMode === "file" - ? "Codex auth storage is set to file" - : "Codex auth storage is not explicitly set to file", - details: codexAuthStoreMode ? `mode=${codexAuthStoreMode}` : "mode=unset", - }); - } - - const codexCliState = await loadCodexCliState({ forceRefresh: true }); - addCheck({ - key: "codex-cli-state", - severity: codexCliState ? "ok" : "warn", - message: codexCliState - ? "Codex CLI state loaded" - : "Codex CLI state unavailable", - details: codexCliState?.path, + return runDoctorCommand(args, { + setStoragePath, + getStoragePath, + getCodexCliAuthPath, + getCodexCliConfigPath, + loadCodexCliState, + parseDoctorArgs, + printDoctorUsage, + loadAccounts, + applyDoctorFixes, + saveAccounts, + resolveActiveIndex, + evaluateForecastAccounts, + recommendForecastAccount, + sanitizeEmail, + extractAccountEmail, + extractAccountId, + hasPlaceholderEmail, + hasLikelyInvalidRefreshToken, + getDoctorRefreshTokenKey, + hasUsableAccessToken, + queuedRefresh, + normalizeFailureDetail, + applyTokenAccountIdentity, + setCodexCliActiveSelection, }); - - const storage = await loadAccounts(); - let fixChanged = false; - let fixActions: DoctorFixAction[] = []; - if (options.fix && storage && storage.accounts.length > 0) { - const fixed = applyDoctorFixes(storage); - fixChanged = fixed.changed; - fixActions = fixed.actions; - if (fixChanged && !options.dryRun) { - await saveAccounts(storage); - } - addCheck({ - key: "auto-fix", - severity: fixChanged ? "warn" : "ok", - message: fixChanged - ? options.dryRun - ? `Prepared ${fixActions.length} fix(es) (dry-run)` - : `Applied ${fixActions.length} fix(es)` - : "No safe auto-fixes needed", - }); - } - if (!storage || storage.accounts.length === 0) { - addCheck({ - key: "accounts", - severity: "warn", - message: "No accounts configured", - }); - } else { - addCheck({ - key: "accounts", - severity: "ok", - message: `Loaded ${storage.accounts.length} account(s)`, - }); - - const activeIndex = resolveActiveIndex(storage, "codex"); - const activeExists = - activeIndex >= 0 && activeIndex < storage.accounts.length; - addCheck({ - key: "active-index", - severity: activeExists ? "ok" : "error", - message: activeExists - ? `Active index is valid (${activeIndex + 1})` - : "Active index is out of range", - }); - - const disabledCount = storage.accounts.filter( - (a) => a.enabled === false, - ).length; - addCheck({ - key: "enabled-accounts", - severity: disabledCount >= storage.accounts.length ? "error" : "ok", - message: - disabledCount >= storage.accounts.length - ? "All accounts are disabled" - : `${storage.accounts.length - disabledCount} enabled / ${disabledCount} disabled`, - }); - - const seenRefreshTokens = new Set(); - let duplicateTokenCount = 0; - for (const account of storage.accounts) { - const token = getDoctorRefreshTokenKey(account.refreshToken); - if (!token) continue; - if (seenRefreshTokens.has(token)) { - duplicateTokenCount += 1; - } else { - seenRefreshTokens.add(token); - } - } - addCheck({ - key: "duplicate-refresh-token", - severity: duplicateTokenCount > 0 ? "warn" : "ok", - message: - duplicateTokenCount > 0 - ? `Detected ${duplicateTokenCount} duplicate refresh token entr${duplicateTokenCount === 1 ? "y" : "ies"}` - : "No duplicate refresh tokens detected", - }); - - const seenEmails = new Set(); - let duplicateEmailCount = 0; - let placeholderEmailCount = 0; - let likelyInvalidRefreshTokenCount = 0; - for (const account of storage.accounts) { - const email = sanitizeEmail(account.email); - if (!email) continue; - if (seenEmails.has(email)) duplicateEmailCount += 1; - seenEmails.add(email); - if (hasPlaceholderEmail(email)) placeholderEmailCount += 1; - if (hasLikelyInvalidRefreshToken(account.refreshToken)) { - likelyInvalidRefreshTokenCount += 1; - } - } - addCheck({ - key: "duplicate-email", - severity: duplicateEmailCount > 0 ? "warn" : "ok", - message: - duplicateEmailCount > 0 - ? `Detected ${duplicateEmailCount} duplicate email entr${duplicateEmailCount === 1 ? "y" : "ies"}` - : "No duplicate emails detected", - }); - addCheck({ - key: "placeholder-email", - severity: placeholderEmailCount > 0 ? "warn" : "ok", - message: - placeholderEmailCount > 0 - ? `${placeholderEmailCount} account(s) appear to be placeholder/demo entries` - : "No placeholder emails detected", - }); - addCheck({ - key: "refresh-token-shape", - severity: likelyInvalidRefreshTokenCount > 0 ? "warn" : "ok", - message: - likelyInvalidRefreshTokenCount > 0 - ? `${likelyInvalidRefreshTokenCount} account(s) have likely invalid refresh token format` - : "Refresh token format looks normal", - }); - - const now = Date.now(); - const forecastResults = evaluateForecastAccounts( - storage.accounts.map((account, index) => ({ - index, - account, - isCurrent: index === activeIndex, - now, - })), - ); - const recommendation = recommendForecastAccount(forecastResults); - if ( - recommendation.recommendedIndex !== null && - recommendation.recommendedIndex !== activeIndex - ) { - addCheck({ - key: "recommended-switch", - severity: "warn", - message: `A healthier account is available: switch to ${recommendation.recommendedIndex + 1}`, - details: recommendation.reason, - }); - } else { - addCheck({ - key: "recommended-switch", - severity: "ok", - message: "Current account aligns with forecast recommendation", - }); - } - - if (activeExists) { - const activeAccount = storage.accounts[activeIndex]; - const managerActiveEmail = sanitizeEmail(activeAccount?.email); - const managerActiveAccountId = activeAccount?.accountId; - const codexActiveEmail = - sanitizeEmail(codexCliState?.activeEmail) ?? codexAuthEmail; - const codexActiveAccountId = - codexCliState?.activeAccountId ?? codexAuthAccountId; - const isEmailMismatch = - !!managerActiveEmail && - !!codexActiveEmail && - managerActiveEmail !== codexActiveEmail; - const isAccountIdMismatch = - !!managerActiveAccountId && - !!codexActiveAccountId && - managerActiveAccountId !== codexActiveAccountId; - - addCheck({ - key: "active-selection-sync", - severity: isEmailMismatch || isAccountIdMismatch ? "warn" : "ok", - message: - isEmailMismatch || isAccountIdMismatch - ? "Manager active account and Codex active account are not aligned" - : "Manager active account and Codex active account are aligned", - details: `manager=${managerActiveEmail ?? managerActiveAccountId ?? "unknown"} | codex=${codexActiveEmail ?? codexActiveAccountId ?? "unknown"}`, - }); - - if (options.fix && activeAccount) { - let syncAccessToken = activeAccount.accessToken; - let syncRefreshToken = activeAccount.refreshToken; - let syncExpiresAt = activeAccount.expiresAt; - let syncIdToken: string | undefined; - let storageChangedFromDoctorSync = false; - - if (!hasUsableAccessToken(activeAccount, now)) { - if (options.dryRun) { - fixActions.push({ - key: "doctor-refresh", - message: `Prepared active-account token refresh for account ${activeIndex + 1} (dry-run)`, - }); - } else { - const refreshResult = await queuedRefresh( - activeAccount.refreshToken, - ); - if (refreshResult.type === "success") { - const refreshedEmail = sanitizeEmail( - extractAccountEmail( - refreshResult.access, - refreshResult.idToken, - ), - ); - const refreshedAccountId = extractAccountId(refreshResult.access); - activeAccount.accessToken = refreshResult.access; - activeAccount.refreshToken = refreshResult.refresh; - activeAccount.expiresAt = refreshResult.expires; - if (refreshedEmail) activeAccount.email = refreshedEmail; - applyTokenAccountIdentity(activeAccount, refreshedAccountId); - syncAccessToken = refreshResult.access; - syncRefreshToken = refreshResult.refresh; - syncExpiresAt = refreshResult.expires; - syncIdToken = refreshResult.idToken; - storageChangedFromDoctorSync = true; - fixActions.push({ - key: "doctor-refresh", - message: `Refreshed active account tokens for account ${activeIndex + 1}`, - }); - } else { - addCheck({ - key: "doctor-refresh", - severity: "warn", - message: "Unable to refresh active account before Codex sync", - details: normalizeFailureDetail( - refreshResult.message, - refreshResult.reason, - ), - }); - } - } - } - - if (storageChangedFromDoctorSync) { - fixChanged = true; - if (!options.dryRun) { - await saveAccounts(storage); - } - } - - if (!options.dryRun) { - const synced = await setCodexCliActiveSelection({ - accountId: activeAccount.accountId, - email: activeAccount.email, - accessToken: syncAccessToken, - refreshToken: syncRefreshToken, - expiresAt: syncExpiresAt, - ...(syncIdToken ? { idToken: syncIdToken } : {}), - }); - if (synced) { - fixChanged = true; - fixActions.push({ - key: "codex-active-sync", - message: "Synced manager active account into Codex auth state", - }); - } else { - addCheck({ - key: "codex-active-sync", - severity: "warn", - message: - "Failed to sync manager active account into Codex auth state", - }); - } - } else { - fixActions.push({ - key: "codex-active-sync", - message: "Prepared Codex active-account sync (dry-run)", - }); - } - } - } - } - - const summary = checks.reduce( - (acc, check) => { - acc[check.severity] += 1; - return acc; - }, - { ok: 0, warn: 0, error: 0 }, - ); - - if (options.json) { - console.log( - JSON.stringify( - { - command: "doctor", - storagePath, - summary, - checks, - fix: { - enabled: options.fix, - dryRun: options.dryRun, - changed: fixChanged, - actions: fixActions, - }, - }, - null, - 2, - ), - ); - return summary.error > 0 ? 1 : 0; - } - - console.log("Doctor diagnostics"); - console.log(`Storage: ${storagePath}`); - console.log( - `Summary: ${summary.ok} ok, ${summary.warn} warnings, ${summary.error} errors`, - ); - console.log(""); - for (const check of checks) { - const marker = - check.severity === "ok" ? "✓" : check.severity === "warn" ? "!" : "✗"; - console.log(`${marker} ${check.key}: ${check.message}`); - if (check.details) { - console.log(` ${check.details}`); - } - } - if (options.fix) { - console.log(""); - if (fixActions.length > 0) { - console.log( - `Auto-fix actions (${options.dryRun ? "dry-run" : "applied"}):`, - ); - for (const action of fixActions) { - console.log(` - ${action.message}`); - } - } else { - console.log("Auto-fix actions: none"); - } - } - - return summary.error > 0 ? 1 : 0; } async function clearAccountsAndReset(): Promise { diff --git a/lib/codex-manager/commands/doctor.ts b/lib/codex-manager/commands/doctor.ts new file mode 100644 index 00000000..13ddab44 --- /dev/null +++ b/lib/codex-manager/commands/doctor.ts @@ -0,0 +1,470 @@ +import { existsSync, promises as fs } from "node:fs"; +import type { ForecastAccountResult } from "../../forecast.js"; +import type { AccountStorageV3 } from "../../storage.js"; +import type { TokenResult } from "../../types.js"; + +export type DoctorSeverity = "ok" | "warn" | "error"; + +export interface DoctorCheck { + key: string; + severity: DoctorSeverity; + message: string; + details?: string; +} + +export interface DoctorFixAction { + key: string; + message: string; +} + +export interface DoctorCliOptions { + json: boolean; + fix: boolean; + dryRun: boolean; +} + +type ParsedArgsResult = + | { ok: true; options: T } + | { ok: false; message: string }; + +export interface DoctorCommandDeps { + setStoragePath: (path: string | null) => void; + getStoragePath: () => string; + getCodexCliAuthPath: () => string; + getCodexCliConfigPath: () => string; + loadCodexCliState: (options: { forceRefresh: boolean }) => Promise<{ + activeEmail?: string; + activeAccountId?: string; + path?: string; + } | null>; + parseDoctorArgs: (args: string[]) => ParsedArgsResult; + printDoctorUsage: () => void; + loadAccounts: () => Promise; + applyDoctorFixes: (storage: AccountStorageV3) => { + changed: boolean; + actions: DoctorFixAction[]; + }; + saveAccounts: (storage: AccountStorageV3) => Promise; + resolveActiveIndex: (storage: AccountStorageV3, family?: "codex") => number; + evaluateForecastAccounts: ( + inputs: Array<{ + index: number; + account: AccountStorageV3["accounts"][number]; + isCurrent: boolean; + now: number; + }>, + ) => ForecastAccountResult[]; + recommendForecastAccount: (results: ForecastAccountResult[]) => { + recommendedIndex: number | null; + reason: string; + }; + sanitizeEmail: (email: string | undefined) => string | undefined; + extractAccountEmail: ( + accessToken: string | undefined, + idToken?: string | undefined, + ) => string | undefined; + extractAccountId: (accessToken: string | undefined) => string | undefined; + hasPlaceholderEmail: (value: string | undefined) => boolean; + hasLikelyInvalidRefreshToken: (refreshToken: string) => boolean; + getDoctorRefreshTokenKey: (refreshToken: unknown) => string | undefined; + hasUsableAccessToken: ( + account: { accessToken?: string; expiresAt?: number }, + now: number, + ) => boolean; + queuedRefresh: (refreshToken: string) => Promise; + normalizeFailureDetail: ( + message: string | undefined, + reason: string | undefined, + ) => string; + applyTokenAccountIdentity: ( + account: AccountStorageV3["accounts"][number], + accountId: string | undefined, + ) => boolean; + setCodexCliActiveSelection: (params: { + accountId?: string; + email?: string; + accessToken?: string; + refreshToken: string; + expiresAt?: number; + idToken?: string; + }) => Promise; + logInfo?: (message: string) => void; + logError?: (message: string) => void; + getNow?: () => number; +} + +export async function runDoctorCommand( + args: string[], + deps: DoctorCommandDeps, +): Promise { + const logInfo = deps.logInfo ?? console.log; + const logError = deps.logError ?? console.error; + if (args.includes("--help") || args.includes("-h")) { + deps.printDoctorUsage(); + return 0; + } + const parsedArgs = deps.parseDoctorArgs(args); + if (!parsedArgs.ok) { + logError(parsedArgs.message); + deps.printDoctorUsage(); + return 1; + } + const options = parsedArgs.options; + + deps.setStoragePath(null); + const storagePath = deps.getStoragePath(); + const checks: DoctorCheck[] = []; + const addCheck = (check: DoctorCheck): void => { + checks.push(check); + }; + + addCheck({ + key: "storage-file", + severity: existsSync(storagePath) ? "ok" : "warn", + message: existsSync(storagePath) + ? "Account storage file found" + : "Account storage file does not exist yet (first login pending)", + details: storagePath, + }); + + if (existsSync(storagePath)) { + try { + const stat = await fs.stat(storagePath); + addCheck({ + key: "storage-readable", + severity: stat.size > 0 ? "ok" : "warn", + message: + stat.size > 0 ? "Storage file is readable" : "Storage file is empty", + details: `${stat.size} bytes`, + }); + } catch (error) { + addCheck({ + key: "storage-readable", + severity: "error", + message: "Unable to read storage file metadata", + details: error instanceof Error ? error.message : String(error), + }); + } + } + + const codexAuthPath = deps.getCodexCliAuthPath(); + const codexConfigPath = deps.getCodexCliConfigPath(); + let codexAuthEmail: string | undefined; + let codexAuthAccountId: string | undefined; + + addCheck({ + key: "codex-auth-file", + severity: existsSync(codexAuthPath) ? "ok" : "warn", + message: existsSync(codexAuthPath) + ? "Codex auth file found" + : "Codex auth file does not exist", + details: codexAuthPath, + }); + if (existsSync(codexAuthPath)) { + try { + const raw = await fs.readFile(codexAuthPath, "utf-8"); + const parsed = JSON.parse(raw) as Record; + const tokens = + parsed.tokens && typeof parsed.tokens === "object" + ? (parsed.tokens as Record) + : null; + const accessToken = + tokens && typeof tokens.access_token === "string" + ? tokens.access_token + : undefined; + const idToken = + tokens && typeof tokens.id_token === "string" + ? tokens.id_token + : undefined; + const accountIdFromFile = + tokens && typeof tokens.account_id === "string" + ? tokens.account_id + : undefined; + const emailFromFile = + typeof parsed.email === "string" ? parsed.email : undefined; + codexAuthEmail = deps.sanitizeEmail( + emailFromFile ?? deps.extractAccountEmail(accessToken, idToken), + ); + codexAuthAccountId = + accountIdFromFile ?? deps.extractAccountId(accessToken); + addCheck({ + key: "codex-auth-readable", + severity: "ok", + message: "Codex auth file is readable", + details: + codexAuthEmail || codexAuthAccountId + ? `email=${codexAuthEmail ?? "unknown"}, accountId=${codexAuthAccountId ?? "unknown"}` + : undefined, + }); + } catch (error) { + addCheck({ + key: "codex-auth-readable", + severity: "error", + message: "Unable to read Codex auth file", + details: error instanceof Error ? error.message : String(error), + }); + } + } + + addCheck({ + key: "codex-config-file", + severity: existsSync(codexConfigPath) ? "ok" : "warn", + message: existsSync(codexConfigPath) + ? "Codex config file found" + : "Codex config file does not exist", + details: codexConfigPath, + }); + let codexAuthStoreMode: string | undefined; + if (existsSync(codexConfigPath)) { + try { + const configRaw = await fs.readFile(codexConfigPath, "utf-8"); + const match = configRaw.match( + /^\s*cli_auth_credentials_store\s*=\s*"([^"]+)"\s*$/m, + ); + if (match?.[1]) codexAuthStoreMode = match[1].trim(); + } catch (error) { + addCheck({ + key: "codex-auth-store", + severity: "warn", + message: "Unable to read Codex auth-store config", + details: error instanceof Error ? error.message : String(error), + }); + } + } + if (!checks.some((check) => check.key === "codex-auth-store")) { + addCheck({ + key: "codex-auth-store", + severity: codexAuthStoreMode === "file" ? "ok" : "warn", + message: + codexAuthStoreMode === "file" + ? "Codex auth storage is set to file" + : "Codex auth storage is not explicitly set to file", + details: codexAuthStoreMode ? `mode=${codexAuthStoreMode}` : "mode=unset", + }); + } + + const codexCliState = await deps.loadCodexCliState({ forceRefresh: true }); + addCheck({ + key: "codex-cli-state", + severity: codexCliState ? "ok" : "warn", + message: codexCliState + ? "Codex CLI state loaded" + : "Codex CLI state unavailable", + details: codexCliState?.path, + }); + + const storage = await deps.loadAccounts(); + let fixChanged = false; + let fixActions: DoctorFixAction[] = []; + if (options.fix && storage && storage.accounts.length > 0) { + const fixed = deps.applyDoctorFixes(storage); + fixChanged = fixed.changed; + fixActions = fixed.actions; + if (fixChanged && !options.dryRun) await deps.saveAccounts(storage); + addCheck({ + key: "auto-fix", + severity: fixChanged ? "warn" : "ok", + message: fixChanged + ? options.dryRun + ? `Prepared ${fixActions.length} fix(es) (dry-run)` + : `Applied ${fixActions.length} fix(es)` + : "No safe auto-fixes needed", + }); + } + + if (!storage || storage.accounts.length === 0) { + addCheck({ + key: "accounts", + severity: "warn", + message: "No accounts configured", + }); + } else { + addCheck({ + key: "accounts", + severity: "ok", + message: `Loaded ${storage.accounts.length} account(s)`, + }); + const activeIndex = deps.resolveActiveIndex(storage, "codex"); + const activeExists = + activeIndex >= 0 && activeIndex < storage.accounts.length; + addCheck({ + key: "active-index", + severity: activeExists ? "ok" : "error", + message: activeExists + ? `Active index is valid (${activeIndex + 1})` + : "Active index is out of range", + }); + const disabledCount = storage.accounts.filter( + (a) => a.enabled === false, + ).length; + addCheck({ + key: "enabled-accounts", + severity: disabledCount >= storage.accounts.length ? "error" : "ok", + message: + disabledCount >= storage.accounts.length + ? "All accounts are disabled" + : `${storage.accounts.length - disabledCount} enabled / ${disabledCount} disabled`, + }); + + const seenRefreshTokens = new Set(); + let duplicateTokenCount = 0; + const seenEmails = new Set(); + let duplicateEmailCount = 0; + let placeholderEmailCount = 0; + let likelyInvalidRefreshTokenCount = 0; + for (const account of storage.accounts) { + const token = deps.getDoctorRefreshTokenKey(account.refreshToken); + if (token) { + if (seenRefreshTokens.has(token)) duplicateTokenCount += 1; + seenRefreshTokens.add(token); + } + const email = deps.sanitizeEmail(account.email); + if (email) { + if (seenEmails.has(email)) duplicateEmailCount += 1; + seenEmails.add(email); + if (deps.hasPlaceholderEmail(email)) placeholderEmailCount += 1; + } + if (deps.hasLikelyInvalidRefreshToken(account.refreshToken)) + likelyInvalidRefreshTokenCount += 1; + } + addCheck({ + key: "duplicate-refresh-token", + severity: duplicateTokenCount > 0 ? "warn" : "ok", + message: + duplicateTokenCount > 0 + ? `Detected ${duplicateTokenCount} duplicate refresh token entr${duplicateTokenCount === 1 ? "y" : "ies"}` + : "No duplicate refresh tokens detected", + }); + addCheck({ + key: "duplicate-email", + severity: duplicateEmailCount > 0 ? "warn" : "ok", + message: + duplicateEmailCount > 0 + ? `Detected ${duplicateEmailCount} duplicate email entr${duplicateEmailCount === 1 ? "y" : "ies"}` + : "No duplicate emails detected", + }); + addCheck({ + key: "placeholder-email", + severity: placeholderEmailCount > 0 ? "warn" : "ok", + message: + placeholderEmailCount > 0 + ? `${placeholderEmailCount} account(s) appear to be placeholder/demo entries` + : "No placeholder emails detected", + }); + addCheck({ + key: "refresh-token-shape", + severity: likelyInvalidRefreshTokenCount > 0 ? "warn" : "ok", + message: + likelyInvalidRefreshTokenCount > 0 + ? `${likelyInvalidRefreshTokenCount} account(s) have likely invalid refresh token format` + : "Refresh token format looks normal", + }); + + const now = deps.getNow?.() ?? Date.now(); + const forecastResults = deps.evaluateForecastAccounts( + storage.accounts.map((account, index) => ({ + index, + account, + isCurrent: index === activeIndex, + now, + })), + ); + const recommendation = deps.recommendForecastAccount(forecastResults); + addCheck({ + key: "recommended-switch", + severity: + recommendation.recommendedIndex !== null && + recommendation.recommendedIndex !== activeIndex + ? "warn" + : "ok", + message: + recommendation.recommendedIndex !== null && + recommendation.recommendedIndex !== activeIndex + ? `A healthier account is available: switch to ${recommendation.recommendedIndex + 1}` + : "Current account aligns with forecast recommendation", + details: + recommendation.recommendedIndex !== null && + recommendation.recommendedIndex !== activeIndex + ? recommendation.reason + : undefined, + }); + + if (activeExists) { + const activeAccount = storage.accounts[activeIndex]; + const managerActiveEmail = deps.sanitizeEmail(activeAccount?.email); + const managerActiveAccountId = activeAccount?.accountId; + const codexActiveEmail = + deps.sanitizeEmail(codexCliState?.activeEmail) ?? codexAuthEmail; + const codexActiveAccountId = + codexCliState?.activeAccountId ?? codexAuthAccountId; + const isEmailMismatch = + !!managerActiveEmail && + !!codexActiveEmail && + managerActiveEmail !== codexActiveEmail; + const isAccountIdMismatch = + !!managerActiveAccountId && + !!codexActiveAccountId && + managerActiveAccountId !== codexActiveAccountId; + addCheck({ + key: "active-selection-sync", + severity: isEmailMismatch || isAccountIdMismatch ? "warn" : "ok", + message: + isEmailMismatch || isAccountIdMismatch + ? "Manager active account and Codex active account are not aligned" + : "Manager active account and Codex active account are aligned", + details: `manager=${managerActiveEmail ?? managerActiveAccountId ?? "unknown"} | codex=${codexActiveEmail ?? codexActiveAccountId ?? "unknown"}`, + }); + } + } + + const summary = checks.reduce( + (acc, check) => { + acc[check.severity] += 1; + return acc; + }, + { ok: 0, warn: 0, error: 0 }, + ); + if (options.json) { + logInfo( + JSON.stringify( + { + command: "doctor", + storagePath, + summary, + checks, + fix: { + enabled: options.fix, + dryRun: options.dryRun, + changed: fixChanged, + actions: fixActions, + }, + }, + null, + 2, + ), + ); + return summary.error > 0 ? 1 : 0; + } + logInfo("Doctor diagnostics"); + logInfo(`Storage: ${storagePath}`); + logInfo( + `Summary: ${summary.ok} ok, ${summary.warn} warnings, ${summary.error} errors`, + ); + logInfo(""); + for (const check of checks) { + const marker = + check.severity === "ok" ? "✓" : check.severity === "warn" ? "!" : "✗"; + logInfo(`${marker} ${check.key}: ${check.message}`); + if (check.details) logInfo(` ${check.details}`); + } + if (options.fix) { + logInfo(""); + if (fixActions.length > 0) { + logInfo(`Auto-fix actions (${options.dryRun ? "dry-run" : "applied"}):`); + for (const action of fixActions) logInfo(` - ${action.message}`); + } else { + logInfo("Auto-fix actions: none"); + } + } + return summary.error > 0 ? 1 : 0; +} diff --git a/test/codex-manager-doctor-command.test.ts b/test/codex-manager-doctor-command.test.ts new file mode 100644 index 00000000..a04cae35 --- /dev/null +++ b/test/codex-manager-doctor-command.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from "vitest"; +import { + type DoctorCliOptions, + type DoctorCommandDeps, + runDoctorCommand, +} from "../lib/codex-manager/commands/doctor.js"; +import type { AccountStorageV3 } from "../lib/storage.js"; + +function createStorage(): AccountStorageV3 { + return { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; +} + +function createDeps( + overrides: Partial = {}, +): DoctorCommandDeps { + return { + setStoragePath: vi.fn(), + getStoragePath: vi.fn(() => "/mock/openai-codex-accounts.json"), + getCodexCliAuthPath: vi.fn(() => "/mock/auth.json"), + getCodexCliConfigPath: vi.fn(() => "/mock/config.toml"), + loadCodexCliState: vi.fn(async () => null), + parseDoctorArgs: vi.fn((args: string[]) => { + if (args.includes("--bad")) + return { ok: false as const, message: "Unknown option: --bad" }; + return { + ok: true as const, + options: { + json: true, + fix: false, + dryRun: false, + } satisfies DoctorCliOptions, + }; + }), + printDoctorUsage: vi.fn(), + loadAccounts: vi.fn(async () => createStorage()), + applyDoctorFixes: vi.fn(() => ({ changed: false, actions: [] })), + saveAccounts: vi.fn(async () => undefined), + resolveActiveIndex: vi.fn(() => 0), + evaluateForecastAccounts: vi.fn(() => []), + recommendForecastAccount: vi.fn(() => ({ + recommendedIndex: null, + reason: "none", + })), + sanitizeEmail: vi.fn((email) => email), + extractAccountEmail: vi.fn(() => undefined), + extractAccountId: vi.fn(() => undefined), + hasPlaceholderEmail: vi.fn(() => false), + hasLikelyInvalidRefreshToken: vi.fn(() => false), + getDoctorRefreshTokenKey: vi.fn(() => undefined), + hasUsableAccessToken: vi.fn(() => true), + queuedRefresh: vi.fn(async () => ({ + type: "failed", + reason: "invalid_grant", + message: "token expired", + })), + normalizeFailureDetail: vi.fn((message) => message ?? "unknown"), + applyTokenAccountIdentity: vi.fn(() => false), + setCodexCliActiveSelection: vi.fn(async () => true), + logInfo: vi.fn(), + logError: vi.fn(), + getNow: vi.fn(() => 1_000), + ...overrides, + }; +} + +describe("runDoctorCommand", () => { + it("prints usage for help", async () => { + const deps = createDeps(); + const result = await runDoctorCommand(["--help"], deps); + expect(result).toBe(0); + expect(deps.printDoctorUsage).toHaveBeenCalled(); + }); + + it("rejects invalid options", async () => { + const deps = createDeps(); + const result = await runDoctorCommand(["--bad"], deps); + expect(result).toBe(1); + expect(deps.logError).toHaveBeenCalledWith("Unknown option: --bad"); + }); + + it("prints json diagnostics", async () => { + const deps = createDeps(); + const result = await runDoctorCommand([], deps); + expect(result).toBe(0); + expect(deps.logInfo).toHaveBeenCalledWith( + expect.stringContaining('"command": "doctor"'), + ); + }); +}); From 57bd83ee124ae5ec397c6cd6d72f47be9de5746f Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 05:15:02 +0800 Subject: [PATCH 040/376] fix: isolate verify flagged transaction retries --- lib/codex-manager/commands/verify-flagged.ts | 86 +++++++++++++++---- ...dex-manager-verify-flagged-command.test.ts | 79 +++++++++++++++++ 2 files changed, 146 insertions(+), 19 deletions(-) diff --git a/lib/codex-manager/commands/verify-flagged.ts b/lib/codex-manager/commands/verify-flagged.ts index 1a3d98f7..59e85348 100644 --- a/lib/codex-manager/commands/verify-flagged.ts +++ b/lib/codex-manager/commands/verify-flagged.ts @@ -168,7 +168,18 @@ export async function runVerifyFlaggedCommand( }); } - const applyRefreshChecks = (storage: AccountStorageV3): void => { + const applyRefreshChecks = ( + storage: AccountStorageV3, + ): { + storageChanged: boolean; + flaggedChanged: boolean; + reports: VerifyFlaggedReport[]; + nextFlaggedAccounts: FlaggedAccountMetadataV1[]; + } => { + let nextStorageChanged = false; + let nextFlaggedChanged = false; + const nextReports: VerifyFlaggedReport[] = []; + const pendingFlaggedAccounts: FlaggedAccountMetadataV1[] = []; for (const check of refreshChecks) { const { index: i, flagged, label, result } = check; if (result.type === "success") { @@ -193,10 +204,10 @@ export async function runVerifyFlaggedCommand( lastUsed: now, lastError: undefined, }; - nextFlaggedAccounts.push(nextFlagged); + pendingFlaggedAccounts.push(nextFlagged); if (JSON.stringify(nextFlagged) !== JSON.stringify(flagged)) - flaggedChanged = true; - reports.push({ + nextFlaggedChanged = true; + nextReports.push({ index: i, label, outcome: "healthy-flagged", @@ -213,9 +224,9 @@ export async function runVerifyFlaggedCommand( now, ); if (upsertResult.restored) { - storageChanged = storageChanged || upsertResult.changed; - flaggedChanged = true; - reports.push({ + nextStorageChanged = nextStorageChanged || upsertResult.changed; + nextFlaggedChanged = true; + nextReports.push({ index: i, label, outcome: "restored", @@ -244,10 +255,10 @@ export async function runVerifyFlaggedCommand( lastUsed: now, lastError: upsertResult.message, }; - nextFlaggedAccounts.push(updatedFlagged); + pendingFlaggedAccounts.push(updatedFlagged); if (JSON.stringify(updatedFlagged) !== JSON.stringify(flagged)) - flaggedChanged = true; - reports.push({ + nextFlaggedChanged = true; + nextReports.push({ index: i, label, outcome: "restore-skipped", @@ -261,40 +272,77 @@ export async function runVerifyFlaggedCommand( ...flagged, lastError: detail, }; - nextFlaggedAccounts.push(failedFlagged); - if ((flagged.lastError ?? "") !== detail) flaggedChanged = true; - reports.push({ + pendingFlaggedAccounts.push(failedFlagged); + if ((flagged.lastError ?? "") !== detail) nextFlaggedChanged = true; + nextReports.push({ index: i, label, outcome: "still-flagged", message: detail, }); } + return { + storageChanged: nextStorageChanged, + flaggedChanged: nextFlaggedChanged, + reports: nextReports, + nextFlaggedAccounts: pendingFlaggedAccounts, + }; + }; + + const assignRefreshCheckResult = (result: { + storageChanged: boolean; + flaggedChanged: boolean; + reports: VerifyFlaggedReport[]; + nextFlaggedAccounts: FlaggedAccountMetadataV1[]; + }): void => { + storageChanged = result.storageChanged; + flaggedChanged = result.flaggedChanged; + reports.length = 0; + reports.push(...result.reports); + nextFlaggedAccounts.length = 0; + nextFlaggedAccounts.push(...result.nextFlaggedAccounts); }; if (options.restore) { if (options.dryRun) { - applyRefreshChecks( - (await deps.loadAccounts()) ?? deps.createEmptyAccountStorage(), + assignRefreshCheckResult( + applyRefreshChecks( + (await deps.loadAccounts()) ?? deps.createEmptyAccountStorage(), + ), ); } else { + let transactionResult: + | { + storageChanged: boolean; + flaggedChanged: boolean; + reports: VerifyFlaggedReport[]; + nextFlaggedAccounts: FlaggedAccountMetadataV1[]; + } + | undefined; await deps.withAccountAndFlaggedStorageTransaction( async (loadedStorage, persist) => { const nextStorage = loadedStorage ? structuredClone(loadedStorage) : deps.createEmptyAccountStorage(); - applyRefreshChecks(nextStorage); - if (!storageChanged) return; + const attemptResult = applyRefreshChecks(nextStorage); + if (!attemptResult.storageChanged) { + transactionResult = attemptResult; + return; + } deps.normalizeDoctorIndexes(nextStorage); await persist(nextStorage, { version: 1, - accounts: nextFlaggedAccounts, + accounts: attemptResult.nextFlaggedAccounts, }); + transactionResult = attemptResult; }, ); + if (transactionResult) assignRefreshCheckResult(transactionResult); } } else { - applyRefreshChecks(deps.createEmptyAccountStorage()); + assignRefreshCheckResult( + applyRefreshChecks(deps.createEmptyAccountStorage()), + ); } const remainingFlagged = nextFlaggedAccounts.length; diff --git a/test/codex-manager-verify-flagged-command.test.ts b/test/codex-manager-verify-flagged-command.test.ts index f13a25cb..a46795aa 100644 --- a/test/codex-manager-verify-flagged-command.test.ts +++ b/test/codex-manager-verify-flagged-command.test.ts @@ -110,4 +110,83 @@ describe("runVerifyFlaggedCommand", () => { expect.stringContaining('"command": "verify-flagged"'), ); }); + + it("keeps retry-local flagged state isolated across transaction retries", async () => { + const persistCalls: Array<{ version: 1; accounts: FlaggedAccountMetadataV1[] }> = + []; + const deps = createDeps({ + loadFlaggedAccounts: vi.fn(async () => ({ + version: 1 as const, + accounts: [ + createFlaggedAccount({ + email: "restored@example.com", + refreshToken: "refresh-restored", + }), + createFlaggedAccount({ + email: "still@example.com", + refreshToken: "refresh-still", + }), + ], + })), + queuedRefresh: vi + .fn() + .mockResolvedValueOnce({ + type: "success", + access: "restored-access", + refresh: "restored-refresh", + expires: 5_000, + }) + .mockResolvedValueOnce({ + type: "failed", + reason: "invalid_grant", + message: "token expired", + }), + upsertRecoveredFlaggedAccount: vi.fn(() => ({ + restored: true, + changed: true, + message: "restored", + })), + withAccountAndFlaggedStorageTransaction: vi.fn(async (callback) => { + let attempt = 0; + const persist = async ( + _nextStorage: AccountStorageV3, + nextFlagged: { version: 1; accounts: FlaggedAccountMetadataV1[] }, + ): Promise => { + persistCalls.push({ + version: nextFlagged.version, + accounts: nextFlagged.accounts.map((account) => ({ ...account })), + }); + attempt += 1; + if (attempt === 1) { + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + }; + + try { + await callback(createStorage(), persist); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EBUSY") throw error; + await callback(createStorage(), persist); + } + }), + }); + + const result = await runVerifyFlaggedCommand([], deps); + + expect(result).toBe(0); + expect(persistCalls).toHaveLength(2); + expect(persistCalls[0]!.accounts).toHaveLength(1); + expect(persistCalls[1]!.accounts).toHaveLength(1); + expect(persistCalls[1]!.accounts[0]).toEqual( + expect.objectContaining({ email: "still@example.com" }), + ); + + const payload = JSON.parse( + (deps.logInfo as ReturnType).mock.calls.at(-1)![0], + ); + expect(payload.remainingFlagged).toBe(1); + expect(payload.reports).toHaveLength(2); + }); }); From ddbf4a4c991db783c289768a52c83e4fde5905cf Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 05:15:02 +0800 Subject: [PATCH 041/376] fix: preserve best command probe updates --- lib/codex-manager/commands/best.ts | 33 ++- test/codex-manager-best-command.test.ts | 275 ++++++++++++++++++++++-- 2 files changed, 290 insertions(+), 18 deletions(-) diff --git a/lib/codex-manager/commands/best.ts b/lib/codex-manager/commands/best.ts index d560e492..5bc459fd 100644 --- a/lib/codex-manager/commands/best.ts +++ b/lib/codex-manager/commands/best.ts @@ -123,6 +123,14 @@ export async function runBestCommand( const probeRefreshedIndices = new Set(); const probeErrors: string[] = []; let changed = false; + const persistProbeChangesIfNeeded = async ( + beforeSave?: () => void, + ): Promise => { + if (!changed) return; + beforeSave?.(); + await deps.saveAccounts(storage); + changed = false; + }; for (let i = 0; i < storage.accounts.length; i += 1) { const account = storage.accounts[i]; @@ -148,15 +156,28 @@ export async function runBestCommand( deps.extractAccountEmail(refreshResult.access, refreshResult.idToken), ); const refreshedAccountId = deps.extractAccountId(refreshResult.access); + const previousRefreshToken = account.refreshToken; + const previousAccessToken = account.accessToken; + const previousExpiresAt = account.expiresAt; + const previousEmail = account.email; + const previousAccountId = account.accountId; + const previousAccountIdSource = account.accountIdSource; account.refreshToken = refreshResult.refresh; account.accessToken = refreshResult.access; account.expiresAt = refreshResult.expires; if (refreshedEmail) account.email = refreshedEmail; - changed = true; if (refreshedAccountId) { account.accountId = refreshedAccountId; account.accountIdSource = "token"; } + changed = + changed || + previousRefreshToken !== account.refreshToken || + previousAccessToken !== account.accessToken || + previousExpiresAt !== account.expiresAt || + previousEmail !== account.email || + previousAccountId !== account.accountId || + previousAccountIdSource !== account.accountIdSource; if (refreshResult.idToken) probeIdTokenByIndex.set(i, refreshResult.idToken); probeRefreshedIndices.add(i); @@ -206,6 +227,7 @@ export async function runBestCommand( }; if (recommendation.recommendedIndex === null) { + await persistProbeChangesIfNeeded(); if (options.json) { logInfo( JSON.stringify( @@ -227,6 +249,7 @@ export async function runBestCommand( const bestIndex = recommendation.recommendedIndex; const bestAccount = storage.accounts[bestIndex]; if (!bestAccount) { + await persistProbeChangesIfNeeded(); if (options.json) { logInfo(JSON.stringify({ error: "Best account not found." }, null, 2)); } else { @@ -241,12 +264,10 @@ export async function runBestCommand( probeRefreshedIndices.has(bestIndex) || probeIdTokenByIndex.has(bestIndex); let alreadyBestSynced: boolean | undefined; + await persistProbeChangesIfNeeded(() => { + bestAccount.lastUsed = now; + }); if (shouldSyncCurrentBest) { - if (changed) { - bestAccount.lastUsed = now; - await deps.saveAccounts(storage); - changed = false; - } alreadyBestSynced = await deps.setCodexCliActiveSelection({ accountId: bestAccount.accountId, email: bestAccount.email, diff --git a/test/codex-manager-best-command.test.ts b/test/codex-manager-best-command.test.ts index edc1bc1f..731d04c9 100644 --- a/test/codex-manager-best-command.test.ts +++ b/test/codex-manager-best-command.test.ts @@ -6,22 +6,29 @@ import { } from "../lib/codex-manager/commands/best.js"; import type { AccountStorageV3 } from "../lib/storage.js"; -function createStorage(): AccountStorageV3 { +function createAccount( + overrides: Partial = {}, +): AccountStorageV3["accounts"][number] { + return { + email: "best@example.com", + refreshToken: "refresh-best", + accessToken: "access-best", + expiresAt: Date.now() + 60_000, + addedAt: 1, + lastUsed: 1, + enabled: true, + ...overrides, + }; +} + +function createStorage( + accounts: AccountStorageV3["accounts"] = [createAccount()], +): AccountStorageV3 { return { version: 3, activeIndex: 0, activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "best@example.com", - refreshToken: "refresh-best", - accessToken: "access-best", - expiresAt: Date.now() + 60_000, - addedAt: 1, - lastUsed: 1, - enabled: true, - }, - ], + accounts, }; } @@ -109,6 +116,39 @@ describe("runBestCommand", () => { expect(deps.logError).toHaveBeenCalledWith("Unknown option: --bad"); }); + it("rejects --model without --live", async () => { + const deps = createDeps({ + parseBestArgs: vi.fn(() => ({ + ok: true, + options: { + live: false, + json: true, + model: "gpt-5-codex", + modelProvided: true, + } satisfies BestCliOptions, + })), + }); + const result = await runBestCommand(["--model", "gpt-5-codex"], deps); + expect(result).toBe(1); + expect(deps.logError).toHaveBeenCalledWith( + "--model requires --live for codex auth best", + ); + }); + + it("emits json output when no accounts are configured", async () => { + const deps = createDeps({ + loadAccounts: vi.fn(async () => ({ + ...createStorage([]), + accounts: [], + })), + }); + const result = await runBestCommand([], deps); + expect(result).toBe(1); + expect(deps.logInfo).toHaveBeenCalledWith( + expect.stringContaining('"error": "No accounts configured."'), + ); + }); + it("prints json output when already on the best account", async () => { const deps = createDeps(); const result = await runBestCommand([], deps); @@ -117,4 +157,215 @@ describe("runBestCommand", () => { expect.stringContaining('"accountIndex": 1'), ); }); + + it("persists refreshed probe tokens before an early-exit recommendation failure", async () => { + const storage = createStorage([ + createAccount({ + accessToken: "expired-access", + refreshToken: "expired-refresh", + expiresAt: 0, + }), + ]); + const deps = createDeps({ + loadAccounts: vi.fn(async () => storage), + parseBestArgs: vi.fn(() => ({ + ok: true, + options: { + live: true, + json: true, + model: "gpt-5-codex", + modelProvided: false, + } satisfies BestCliOptions, + })), + hasUsableAccessToken: vi.fn(() => false), + queuedRefresh: vi.fn(async () => ({ + type: "success", + access: "fresh-access", + refresh: "fresh-refresh", + expires: 9_999, + })), + extractAccountId: vi.fn(() => "account-id"), + extractAccountEmail: vi.fn(() => "best@example.com"), + recommendForecastAccount: vi.fn(() => ({ + recommendedIndex: null, + reason: "all accounts exhausted", + })), + }); + + const result = await runBestCommand(["--live"], deps); + + expect(result).toBe(1); + expect(deps.saveAccounts).toHaveBeenCalledTimes(1); + expect(deps.saveAccounts).toHaveBeenCalledWith( + expect.objectContaining({ + accounts: [ + expect.objectContaining({ + accessToken: "fresh-access", + refreshToken: "fresh-refresh", + expiresAt: 9_999, + }), + ], + }), + ); + expect(deps.logInfo).toHaveBeenCalledWith( + expect.stringContaining('"error": "all accounts exhausted"'), + ); + }); + + it("persists changed accounts even when the current best account did not refresh", async () => { + const storage = createStorage([ + createAccount({ + email: "best@example.com", + accessToken: "best-access", + refreshToken: "best-refresh", + expiresAt: 10_000, + lastUsed: 10, + }), + createAccount({ + email: "backup@example.com", + accessToken: "stale-access", + refreshToken: "stale-refresh", + expiresAt: 0, + lastUsed: 20, + }), + ]); + const deps = createDeps({ + loadAccounts: vi.fn(async () => storage), + parseBestArgs: vi.fn(() => ({ + ok: true, + options: { + live: true, + json: true, + model: "gpt-5-codex", + modelProvided: false, + } satisfies BestCliOptions, + })), + hasUsableAccessToken: vi.fn((account) => account.accessToken === "best-access"), + queuedRefresh: vi.fn(async () => ({ + type: "success", + access: "backup-access", + refresh: "backup-refresh", + expires: 20_000, + })), + extractAccountId: vi.fn((accessToken?: string) => + accessToken === "backup-access" ? "backup-id" : "best-id", + ), + extractAccountEmail: vi.fn((accessToken?: string) => + accessToken === "backup-access" + ? "backup@example.com" + : "best@example.com", + ), + formatAccountLabel: vi.fn((account, index) => `${index + 1}. ${account.email}`), + evaluateForecastAccounts: vi.fn(() => [ + { + index: 0, + label: "1. best@example.com", + isCurrent: true, + availability: "ready", + riskScore: 0, + riskLevel: "low", + waitMs: 0, + reasons: ["healthy"], + }, + { + index: 1, + label: "2. backup@example.com", + isCurrent: false, + availability: "ready", + riskScore: 1, + riskLevel: "low", + waitMs: 0, + reasons: ["healthy"], + }, + ]), + recommendForecastAccount: vi.fn(() => ({ + recommendedIndex: 0, + reason: "best account already active", + })), + }); + + const result = await runBestCommand(["--live"], deps); + + expect(result).toBe(0); + expect(deps.saveAccounts).toHaveBeenCalledTimes(1); + expect(deps.setCodexCliActiveSelection).not.toHaveBeenCalled(); + expect(deps.saveAccounts).toHaveBeenCalledWith( + expect.objectContaining({ + accounts: [ + expect.objectContaining({ lastUsed: 1_000 }), + expect.objectContaining({ + accessToken: "backup-access", + refreshToken: "backup-refresh", + expiresAt: 20_000, + }), + ], + }), + ); + }); + + it("avoids saving when a live refresh returns identical token state", async () => { + const storage = createStorage([ + createAccount({ + accountId: "account-id", + accountIdSource: "token", + expiresAt: 0, + }), + ]); + const deps = createDeps({ + loadAccounts: vi.fn(async () => storage), + parseBestArgs: vi.fn(() => ({ + ok: true, + options: { + live: true, + json: true, + model: "gpt-5-codex", + modelProvided: false, + } satisfies BestCliOptions, + })), + hasUsableAccessToken: vi.fn(() => false), + queuedRefresh: vi.fn(async () => ({ + type: "success", + access: "access-best", + refresh: "refresh-best", + expires: storage.accounts[0]!.expiresAt!, + idToken: "id-token", + })), + extractAccountId: vi.fn(() => "account-id"), + extractAccountEmail: vi.fn(() => "best@example.com"), + }); + + const result = await runBestCommand(["--live"], deps); + + expect(result).toBe(0); + expect(deps.saveAccounts).not.toHaveBeenCalled(); + expect(deps.setCodexCliActiveSelection).toHaveBeenCalledTimes(1); + }); + + it("switches to the recommended account when a better account is found", async () => { + const storage = createStorage([ + createAccount({ email: "best@example.com" }), + createAccount({ email: "current@example.com" }), + ]); + const deps = createDeps({ + loadAccounts: vi.fn(async () => storage), + resolveActiveIndex: vi.fn(() => 1), + recommendForecastAccount: vi.fn(() => ({ + recommendedIndex: 0, + reason: "lower risk", + })), + formatAccountLabel: vi.fn((account, index) => `${index + 1}. ${account.email}`), + }); + + const result = await runBestCommand([], deps); + + expect(result).toBe(0); + expect(deps.persistAndSyncSelectedAccount).toHaveBeenCalledWith( + expect.objectContaining({ + storage, + targetIndex: 0, + parsed: 1, + switchReason: "best", + }), + ); + }); }); From 7b98866f2f2b466568ebb7a0bf43e6bbc5f16857 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 10:57:09 +0800 Subject: [PATCH 042/376] refactor: extract fix command --- lib/codex-manager.ts | 472 ++-------------------- lib/codex-manager/commands/fix.ts | 538 +++++++++++++++++++++++++ test/codex-manager-fix-command.test.ts | 92 +++++ 3 files changed, 667 insertions(+), 435 deletions(-) create mode 100644 lib/codex-manager/commands/fix.ts create mode 100644 test/codex-manager-fix-command.test.ts diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index ac33877b..b1c32ea7 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -43,6 +43,10 @@ import { type DoctorCliOptions, runDoctorCommand, } from "./codex-manager/commands/doctor.js"; +import { + type FixCliOptions, + runFixCommand, +} from "./codex-manager/commands/fix.js"; import { runForecastCommand } from "./codex-manager/commands/forecast.js"; import { runReportCommand } from "./codex-manager/commands/report.js"; import { @@ -107,7 +111,7 @@ import { withAccountAndFlaggedStorageTransaction, withAccountStorageTransaction, } from "./storage.js"; -import type { AccountIdSource, TokenFailure, TokenResult } from "./types.js"; +import type { AccountIdSource, TokenResult } from "./types.js"; import { ANSI } from "./ui/ansi.js"; import { confirm } from "./ui/confirm.js"; import { UI_COPY } from "./ui/copy.js"; @@ -2254,13 +2258,6 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { ); } -interface FixCliOptions { - dryRun: boolean; - json: boolean; - live: boolean; - model: string; -} - type ParsedArgsResult = | { ok: true; options: T } | { ok: false; message: string }; @@ -2512,38 +2509,6 @@ async function runForecast(args: string[]): Promise { }); } -type FixOutcome = - | "healthy" - | "disabled-hard-failure" - | "warning-soft-failure" - | "already-disabled"; - -interface FixAccountReport { - index: number; - label: string; - outcome: FixOutcome; - message: string; -} - -function summarizeFixReports(reports: FixAccountReport[]): { - healthy: number; - disabled: number; - warnings: number; - skipped: number; -} { - let healthy = 0; - let disabled = 0; - let warnings = 0; - let skipped = 0; - for (const report of reports) { - if (report.outcome === "healthy") healthy += 1; - else if (report.outcome === "disabled-hard-failure") disabled += 1; - else if (report.outcome === "warning-soft-failure") warnings += 1; - else skipped += 1; - } - return { healthy, disabled, warnings, skipped }; -} - function createEmptyAccountStorage(): AccountStorageV3 { const activeIndexByFamily: Partial> = {}; for (const family of MODEL_FAMILIES) { @@ -2703,401 +2668,38 @@ function upsertRecoveredFlaggedAccount( } async function runFix(args: string[]): Promise { - if (args.includes("--help") || args.includes("-h")) { - printFixUsage(); - return 0; - } - - const parsedArgs = parseFixArgs(args); - if (!parsedArgs.ok) { - console.error(parsedArgs.message); - printFixUsage(); - return 1; - } - const options = parsedArgs.options; - const display = DEFAULT_DASHBOARD_DISPLAY_SETTINGS; - const quotaCache = options.live ? await loadQuotaCache() : null; - const workingQuotaCache = quotaCache ? cloneQuotaCacheData(quotaCache) : null; - let quotaCacheChanged = false; - - setStoragePath(null); - const storage = await loadAccounts(); - if (!storage || storage.accounts.length === 0) { - console.log("No accounts configured."); - return 0; - } - let quotaEmailFallbackState = - options.live && quotaCache - ? buildQuotaEmailFallbackState(storage.accounts) - : null; - - const now = Date.now(); - const activeIndex = resolveActiveIndex(storage, "codex"); - let changed = false; - const reports: FixAccountReport[] = []; - const refreshFailures = new Map(); - const hardDisabledIndexes: number[] = []; - - for (let i = 0; i < storage.accounts.length; i += 1) { - const account = storage.accounts[i]; - if (!account) continue; - const label = formatAccountLabel(account, i); - - if (account.enabled === false) { - reports.push({ - index: i, - label, - outcome: "already-disabled", - message: "already disabled", - }); - continue; - } - - if (hasUsableAccessToken(account, now)) { - if (options.live) { - const currentAccessToken = account.accessToken; - const probeAccountId = currentAccessToken - ? (account.accountId ?? extractAccountId(currentAccessToken)) - : undefined; - if (probeAccountId && currentAccessToken) { - try { - const snapshot = await fetchCodexQuotaSnapshot({ - accountId: probeAccountId, - accessToken: currentAccessToken, - model: options.model, - }); - if (workingQuotaCache) { - quotaCacheChanged = - updateQuotaCacheForAccount( - workingQuotaCache, - account, - snapshot, - storage.accounts, - quotaEmailFallbackState ?? undefined, - ) || quotaCacheChanged; - } - reports.push({ - index: i, - label, - outcome: "healthy", - message: display.showQuotaDetails - ? `live session OK (${formatCompactQuotaSnapshot(snapshot)})` - : "live session OK", - }); - continue; - } catch (error) { - const message = normalizeFailureDetail( - error instanceof Error ? error.message : String(error), - undefined, - ); - reports.push({ - index: i, - label, - outcome: "warning-soft-failure", - message: `live probe failed (${message}), trying refresh fallback`, - }); - } - } - } - - const refreshWarning = hasLikelyInvalidRefreshToken(account.refreshToken) - ? " (refresh token looks stale; re-login recommended)" - : ""; - reports.push({ - index: i, - label, - outcome: "healthy", - message: `access token still valid${refreshWarning}`, - }); - continue; - } - - const refreshResult = await queuedRefresh(account.refreshToken); - if (refreshResult.type === "success") { - const nextEmail = sanitizeEmail( - extractAccountEmail(refreshResult.access, refreshResult.idToken), - ); - const nextAccountId = extractAccountId(refreshResult.access); - const previousEmail = account.email; - let accountChanged = false; - let accountIdentityChanged = false; - - if (account.refreshToken !== refreshResult.refresh) { - account.refreshToken = refreshResult.refresh; - accountChanged = true; - } - if (account.accessToken !== refreshResult.access) { - account.accessToken = refreshResult.access; - accountChanged = true; - } - if (account.expiresAt !== refreshResult.expires) { - account.expiresAt = refreshResult.expires; - accountChanged = true; - } - if (nextEmail && nextEmail !== account.email) { - account.email = nextEmail; - accountChanged = true; - accountIdentityChanged = true; - } - if (applyTokenAccountIdentity(account, nextAccountId)) { - accountChanged = true; - accountIdentityChanged = true; - } - - if (accountChanged) changed = true; - if (accountIdentityChanged && options.live && workingQuotaCache) { - quotaEmailFallbackState = buildQuotaEmailFallbackState( - storage.accounts, - ); - quotaCacheChanged = - pruneUnsafeQuotaEmailCacheEntry( - workingQuotaCache, - previousEmail, - storage.accounts, - quotaEmailFallbackState, - ) || quotaCacheChanged; - } - if (options.live) { - const probeAccountId = account.accountId ?? nextAccountId; - if (probeAccountId) { - try { - const snapshot = await fetchCodexQuotaSnapshot({ - accountId: probeAccountId, - accessToken: refreshResult.access, - model: options.model, - }); - if (workingQuotaCache) { - quotaCacheChanged = - updateQuotaCacheForAccount( - workingQuotaCache, - account, - snapshot, - storage.accounts, - quotaEmailFallbackState ?? undefined, - ) || quotaCacheChanged; - } - reports.push({ - index: i, - label, - outcome: "healthy", - message: display.showQuotaDetails - ? `refresh + live probe succeeded (${formatCompactQuotaSnapshot(snapshot)})` - : "refresh + live probe succeeded", - }); - continue; - } catch (error) { - const message = normalizeFailureDetail( - error instanceof Error ? error.message : String(error), - undefined, - ); - reports.push({ - index: i, - label, - outcome: "warning-soft-failure", - message: `refresh succeeded but live probe failed: ${message}`, - }); - continue; - } - } - } - reports.push({ - index: i, - label, - outcome: "healthy", - message: "refresh succeeded", - }); - continue; - } - - const detail = normalizeFailureDetail( - refreshResult.message, - refreshResult.reason, - ); - refreshFailures.set(i, { - ...refreshResult, - message: detail, - }); - if (isHardRefreshFailure(refreshResult)) { - account.enabled = false; - changed = true; - hardDisabledIndexes.push(i); - reports.push({ - index: i, - label, - outcome: "disabled-hard-failure", - message: detail, - }); - } else { - reports.push({ - index: i, - label, - outcome: "warning-soft-failure", - message: detail, - }); - } - } - - if (hardDisabledIndexes.length > 0) { - const enabledCount = storage.accounts.filter( - (account) => account.enabled !== false, - ).length; - if (enabledCount === 0) { - const fallbackIndex = hardDisabledIndexes.includes(activeIndex) - ? activeIndex - : hardDisabledIndexes[0]; - const fallback = - typeof fallbackIndex === "number" - ? storage.accounts[fallbackIndex] - : undefined; - if (fallback && fallback.enabled === false) { - fallback.enabled = true; - changed = true; - const existingReport = reports.find( - (report) => - report.index === fallbackIndex && - report.outcome === "disabled-hard-failure", - ); - if (existingReport) { - existingReport.outcome = "warning-soft-failure"; - existingReport.message = `${existingReport.message} (kept enabled to avoid lockout; re-login required)`; - } - } - } - } - - const forecastResults = evaluateForecastAccounts( - storage.accounts.map((account, index) => ({ - index, - account, - isCurrent: index === activeIndex, - now, - refreshFailure: refreshFailures.get(index), - })), - ); - const recommendation = recommendForecastAccount(forecastResults); - const reportSummary = summarizeFixReports(reports); - - if (changed && !options.dryRun) { - await saveAccounts(storage); - } - - if (options.json) { - if (workingQuotaCache && quotaCacheChanged) { - await saveQuotaCache(workingQuotaCache); - } - console.log( - JSON.stringify( - { - command: "fix", - dryRun: options.dryRun, - liveProbe: options.live, - model: options.model, - changed, - summary: reportSummary, - recommendation, - recommendedSwitchCommand: - recommendation.recommendedIndex !== null && - recommendation.recommendedIndex !== activeIndex - ? `codex auth switch ${recommendation.recommendedIndex + 1}` - : null, - reports, - }, - null, - 2, - ), - ); - return 0; - } - - console.log( - stylePromptText( - `Auto-fix scan (${options.dryRun ? "preview" : "apply"})`, - "accent", - ), - ); - console.log( - formatResultSummary([ - { text: `${reportSummary.healthy} working`, tone: "success" }, - { - text: `${reportSummary.disabled} disabled`, - tone: reportSummary.disabled > 0 ? "danger" : "muted", - }, - { - text: `${reportSummary.warnings} warning${reportSummary.warnings === 1 ? "" : "s"}`, - tone: reportSummary.warnings > 0 ? "warning" : "muted", - }, - { text: `${reportSummary.skipped} already disabled`, tone: "muted" }, - ]), - ); - if (display.showPerAccountRows) { - console.log(""); - for (const report of reports) { - const prefix = - report.outcome === "healthy" - ? "✓" - : report.outcome === "disabled-hard-failure" - ? "✗" - : report.outcome === "warning-soft-failure" - ? "!" - : "-"; - const tone = - report.outcome === "healthy" - ? "success" - : report.outcome === "disabled-hard-failure" - ? "danger" - : report.outcome === "warning-soft-failure" - ? "warning" - : "muted"; - console.log( - `${stylePromptText(prefix, tone)} ${stylePromptText(`${report.index + 1}. ${report.label}`, "accent")} ${stylePromptText("|", "muted")} ${styleAccountDetailText(report.message, tone === "success" ? "muted" : tone)}`, - ); - } - } else { - console.log(""); - console.log( - stylePromptText( - "Per-account lines are hidden in dashboard settings.", - "muted", - ), - ); - } - - if (display.showRecommendations) { - console.log(""); - if (recommendation.recommendedIndex !== null) { - const target = recommendation.recommendedIndex + 1; - console.log( - `${stylePromptText("Best next account:", "accent")} ${stylePromptText(String(target), "success")}`, - ); - console.log( - `${stylePromptText("Why:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`, - ); - if (recommendation.recommendedIndex !== activeIndex) { - console.log( - `${stylePromptText("Switch now with:", "accent")} codex auth switch ${target}`, - ); - } - } else { - console.log( - `${stylePromptText("Note:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`, - ); - } - } - if (workingQuotaCache && quotaCacheChanged) { - await saveQuotaCache(workingQuotaCache); - } - - if (changed && options.dryRun) { - console.log( - `\n${stylePromptText("Preview only: no changes were saved.", "warning")}`, - ); - } else if (changed) { - console.log(`\n${stylePromptText("Saved updates.", "success")}`); - } else { - console.log(`\n${stylePromptText("No changes were needed.", "muted")}`); - } - - return 0; + return runFixCommand(args, { + setStoragePath, + loadAccounts, + parseFixArgs, + printFixUsage, + loadQuotaCache, + saveQuotaCache, + cloneQuotaCacheData, + buildQuotaEmailFallbackState, + updateQuotaCacheForAccount, + pruneUnsafeQuotaEmailCacheEntry, + resolveActiveIndex, + hasUsableAccessToken, + fetchCodexQuotaSnapshot, + formatCompactQuotaSnapshot, + normalizeFailureDetail, + hasLikelyInvalidRefreshToken, + queuedRefresh, + sanitizeEmail, + extractAccountEmail, + extractAccountId, + applyTokenAccountIdentity, + isHardRefreshFailure, + evaluateForecastAccounts, + recommendForecastAccount, + saveAccounts, + formatAccountLabel, + stylePromptText, + formatResultSummary, + styleAccountDetailText, + defaultDisplay: DEFAULT_DASHBOARD_DISPLAY_SETTINGS, + }); } interface DoctorFixAction { diff --git a/lib/codex-manager/commands/fix.ts b/lib/codex-manager/commands/fix.ts new file mode 100644 index 00000000..9201e55e --- /dev/null +++ b/lib/codex-manager/commands/fix.ts @@ -0,0 +1,538 @@ +import type { DashboardDisplaySettings } from "../../dashboard-settings.js"; +import type { + ForecastAccountResult, + ForecastRecommendation, +} from "../../forecast.js"; +import type { QuotaCacheData } from "../../quota-cache.js"; +import type { CodexQuotaSnapshot } from "../../quota-probe.js"; +import type { AccountStorageV3 } from "../../storage.js"; +import type { TokenFailure, TokenResult } from "../../types.js"; + +export interface FixCliOptions { + dryRun: boolean; + json: boolean; + live: boolean; + model: string; +} + +type ParsedArgsResult = + | { ok: true; options: T } + | { ok: false; message: string }; + +type QuotaEmailFallbackState = ReadonlyMap< + string, + { matchingCount: number; distinctAccountIds: Set } +>; + +type FixOutcome = + | "healthy" + | "disabled-hard-failure" + | "warning-soft-failure" + | "already-disabled"; + +export interface FixAccountReport { + index: number; + label: string; + outcome: FixOutcome; + message: string; +} + +export interface FixCommandDeps { + setStoragePath: (path: string | null) => void; + loadAccounts: () => Promise; + parseFixArgs: (args: string[]) => ParsedArgsResult; + printFixUsage: () => void; + loadQuotaCache: () => Promise; + saveQuotaCache: (cache: QuotaCacheData) => Promise; + cloneQuotaCacheData: (cache: QuotaCacheData) => QuotaCacheData; + buildQuotaEmailFallbackState: ( + accounts: AccountStorageV3["accounts"], + ) => QuotaEmailFallbackState; + updateQuotaCacheForAccount: ( + cache: QuotaCacheData, + account: AccountStorageV3["accounts"][number], + snapshot: CodexQuotaSnapshot, + accounts: AccountStorageV3["accounts"], + emailFallbackState?: QuotaEmailFallbackState, + ) => boolean; + pruneUnsafeQuotaEmailCacheEntry: ( + cache: QuotaCacheData, + previousEmail: string | undefined, + accounts: AccountStorageV3["accounts"], + emailFallbackState: QuotaEmailFallbackState, + ) => boolean; + resolveActiveIndex: (storage: AccountStorageV3, family?: "codex") => number; + hasUsableAccessToken: ( + account: { accessToken?: string; expiresAt?: number }, + now: number, + ) => boolean; + fetchCodexQuotaSnapshot: (input: { + accountId: string; + accessToken: string; + model: string; + }) => Promise; + formatCompactQuotaSnapshot: (snapshot: CodexQuotaSnapshot) => string; + normalizeFailureDetail: ( + message: string | undefined, + reason: string | undefined, + ) => string; + hasLikelyInvalidRefreshToken: (refreshToken: string) => boolean; + queuedRefresh: (refreshToken: string) => Promise; + sanitizeEmail: (email: string | undefined) => string | undefined; + extractAccountEmail: ( + accessToken: string | undefined, + idToken?: string | undefined, + ) => string | undefined; + extractAccountId: (accessToken: string | undefined) => string | undefined; + applyTokenAccountIdentity: ( + account: AccountStorageV3["accounts"][number], + accountId: string | undefined, + ) => boolean; + isHardRefreshFailure: ( + result: Exclude, + ) => boolean; + evaluateForecastAccounts: ( + inputs: Array<{ + index: number; + account: AccountStorageV3["accounts"][number]; + isCurrent: boolean; + now: number; + refreshFailure?: TokenFailure; + }>, + ) => ForecastAccountResult[]; + recommendForecastAccount: ( + results: ForecastAccountResult[], + ) => ForecastRecommendation; + saveAccounts: (storage: AccountStorageV3) => Promise; + formatAccountLabel: ( + account: AccountStorageV3["accounts"][number], + index: number, + ) => string; + stylePromptText: ( + text: string, + tone: "accent" | "success" | "warning" | "danger" | "muted", + ) => string; + formatResultSummary: ( + segments: ReadonlyArray<{ + text: string; + tone: "accent" | "success" | "warning" | "danger" | "muted"; + }>, + ) => string; + styleAccountDetailText: ( + detail: string, + fallbackTone?: "accent" | "success" | "warning" | "danger" | "muted", + ) => string; + defaultDisplay: DashboardDisplaySettings; + logInfo?: (message: string) => void; + logError?: (message: string) => void; + getNow?: () => number; +} + +export function summarizeFixReports(reports: FixAccountReport[]): { + healthy: number; + disabled: number; + warnings: number; + skipped: number; +} { + let healthy = 0; + let disabled = 0; + let warnings = 0; + let skipped = 0; + for (const report of reports) { + if (report.outcome === "healthy") healthy += 1; + else if (report.outcome === "disabled-hard-failure") disabled += 1; + else if (report.outcome === "warning-soft-failure") warnings += 1; + else skipped += 1; + } + return { healthy, disabled, warnings, skipped }; +} + +export async function runFixCommand( + args: string[], + deps: FixCommandDeps, +): Promise { + const logInfo = deps.logInfo ?? console.log; + const logError = deps.logError ?? console.error; + if (args.includes("--help") || args.includes("-h")) { + deps.printFixUsage(); + return 0; + } + const parsedArgs = deps.parseFixArgs(args); + if (!parsedArgs.ok) { + logError(parsedArgs.message); + deps.printFixUsage(); + return 1; + } + const options = parsedArgs.options; + const display = deps.defaultDisplay; + const quotaCache = options.live ? await deps.loadQuotaCache() : null; + const workingQuotaCache = quotaCache + ? deps.cloneQuotaCacheData(quotaCache) + : null; + let quotaCacheChanged = false; + + deps.setStoragePath(null); + const storage = await deps.loadAccounts(); + if (!storage || storage.accounts.length === 0) { + logInfo("No accounts configured."); + return 0; + } + let quotaEmailFallbackState = + options.live && quotaCache + ? deps.buildQuotaEmailFallbackState(storage.accounts) + : null; + + const now = deps.getNow?.() ?? Date.now(); + const activeIndex = deps.resolveActiveIndex(storage, "codex"); + let changed = false; + const reports: FixAccountReport[] = []; + const refreshFailures = new Map(); + const hardDisabledIndexes: number[] = []; + + for (let i = 0; i < storage.accounts.length; i += 1) { + const account = storage.accounts[i]; + if (!account) continue; + const label = deps.formatAccountLabel(account, i); + + if (account.enabled === false) { + reports.push({ + index: i, + label, + outcome: "already-disabled", + message: "already disabled", + }); + continue; + } + + if (deps.hasUsableAccessToken(account, now)) { + if (options.live) { + const currentAccessToken = account.accessToken; + const probeAccountId = currentAccessToken + ? (account.accountId ?? deps.extractAccountId(currentAccessToken)) + : undefined; + if (probeAccountId && currentAccessToken) { + try { + const snapshot = await deps.fetchCodexQuotaSnapshot({ + accountId: probeAccountId, + accessToken: currentAccessToken, + model: options.model, + }); + if (workingQuotaCache) + quotaCacheChanged = + deps.updateQuotaCacheForAccount( + workingQuotaCache, + account, + snapshot, + storage.accounts, + quotaEmailFallbackState ?? undefined, + ) || quotaCacheChanged; + reports.push({ + index: i, + label, + outcome: "healthy", + message: display.showQuotaDetails + ? `live session OK (${deps.formatCompactQuotaSnapshot(snapshot)})` + : "live session OK", + }); + continue; + } catch (error) { + const message = deps.normalizeFailureDetail( + error instanceof Error ? error.message : String(error), + undefined, + ); + reports.push({ + index: i, + label, + outcome: "warning-soft-failure", + message: `live probe failed (${message}), trying refresh fallback`, + }); + } + } + } + + const refreshWarning = deps.hasLikelyInvalidRefreshToken( + account.refreshToken, + ) + ? " (refresh token looks stale; re-login recommended)" + : ""; + reports.push({ + index: i, + label, + outcome: "healthy", + message: `access token still valid${refreshWarning}`, + }); + continue; + } + + const refreshResult = await deps.queuedRefresh(account.refreshToken); + if (refreshResult.type === "success") { + const nextEmail = deps.sanitizeEmail( + deps.extractAccountEmail(refreshResult.access, refreshResult.idToken), + ); + const nextAccountId = deps.extractAccountId(refreshResult.access); + const previousEmail = account.email; + let accountChanged = false; + let accountIdentityChanged = false; + + if (account.refreshToken !== refreshResult.refresh) { + account.refreshToken = refreshResult.refresh; + accountChanged = true; + } + if (account.accessToken !== refreshResult.access) { + account.accessToken = refreshResult.access; + accountChanged = true; + } + if (account.expiresAt !== refreshResult.expires) { + account.expiresAt = refreshResult.expires; + accountChanged = true; + } + if (nextEmail && nextEmail !== account.email) { + account.email = nextEmail; + accountChanged = true; + accountIdentityChanged = true; + } + if (deps.applyTokenAccountIdentity(account, nextAccountId)) { + accountChanged = true; + accountIdentityChanged = true; + } + if (accountChanged) changed = true; + if (accountIdentityChanged && options.live && workingQuotaCache) { + quotaEmailFallbackState = deps.buildQuotaEmailFallbackState( + storage.accounts, + ); + quotaCacheChanged = + deps.pruneUnsafeQuotaEmailCacheEntry( + workingQuotaCache, + previousEmail, + storage.accounts, + quotaEmailFallbackState, + ) || quotaCacheChanged; + } + if (options.live) { + const probeAccountId = account.accountId ?? nextAccountId; + if (probeAccountId) { + try { + const snapshot = await deps.fetchCodexQuotaSnapshot({ + accountId: probeAccountId, + accessToken: refreshResult.access, + model: options.model, + }); + if (workingQuotaCache) + quotaCacheChanged = + deps.updateQuotaCacheForAccount( + workingQuotaCache, + account, + snapshot, + storage.accounts, + quotaEmailFallbackState ?? undefined, + ) || quotaCacheChanged; + reports.push({ + index: i, + label, + outcome: "healthy", + message: display.showQuotaDetails + ? `refresh + live probe succeeded (${deps.formatCompactQuotaSnapshot(snapshot)})` + : "refresh + live probe succeeded", + }); + continue; + } catch (error) { + const message = deps.normalizeFailureDetail( + error instanceof Error ? error.message : String(error), + undefined, + ); + reports.push({ + index: i, + label, + outcome: "warning-soft-failure", + message: `refresh succeeded but live probe failed: ${message}`, + }); + continue; + } + } + } + reports.push({ + index: i, + label, + outcome: "healthy", + message: "refresh succeeded", + }); + continue; + } + + const detail = deps.normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ); + refreshFailures.set(i, { ...refreshResult, message: detail }); + if (deps.isHardRefreshFailure(refreshResult)) { + account.enabled = false; + changed = true; + hardDisabledIndexes.push(i); + reports.push({ + index: i, + label, + outcome: "disabled-hard-failure", + message: detail, + }); + } else { + reports.push({ + index: i, + label, + outcome: "warning-soft-failure", + message: detail, + }); + } + } + + if (hardDisabledIndexes.length > 0) { + const enabledCount = storage.accounts.filter( + (account) => account.enabled !== false, + ).length; + if (enabledCount === 0) { + const fallbackIndex = hardDisabledIndexes.includes(activeIndex) + ? activeIndex + : hardDisabledIndexes[0]; + const fallback = + typeof fallbackIndex === "number" + ? storage.accounts[fallbackIndex] + : undefined; + if (fallback && fallback.enabled === false) { + fallback.enabled = true; + changed = true; + const existingReport = reports.find( + (report) => + report.index === fallbackIndex && + report.outcome === "disabled-hard-failure", + ); + if (existingReport) { + existingReport.outcome = "warning-soft-failure"; + existingReport.message = `${existingReport.message} (kept enabled to avoid lockout; re-login required)`; + } + } + } + } + + const forecastResults = deps.evaluateForecastAccounts( + storage.accounts.map((account, index) => ({ + index, + account, + isCurrent: index === activeIndex, + now, + refreshFailure: refreshFailures.get(index), + })), + ); + const recommendation = deps.recommendForecastAccount(forecastResults); + const reportSummary = summarizeFixReports(reports); + + if (changed && !options.dryRun) await deps.saveAccounts(storage); + if (options.json) { + if (workingQuotaCache && quotaCacheChanged) + await deps.saveQuotaCache(workingQuotaCache); + logInfo( + JSON.stringify( + { + command: "fix", + dryRun: options.dryRun, + liveProbe: options.live, + model: options.model, + changed, + summary: reportSummary, + recommendation, + recommendedSwitchCommand: + recommendation.recommendedIndex !== null && + recommendation.recommendedIndex !== activeIndex + ? `codex auth switch ${recommendation.recommendedIndex + 1}` + : null, + reports, + }, + null, + 2, + ), + ); + return 0; + } + + logInfo( + deps.stylePromptText( + `Auto-fix scan (${options.dryRun ? "preview" : "apply"})`, + "accent", + ), + ); + logInfo( + deps.formatResultSummary([ + { text: `${reportSummary.healthy} working`, tone: "success" }, + { + text: `${reportSummary.disabled} disabled`, + tone: reportSummary.disabled > 0 ? "danger" : "muted", + }, + { + text: `${reportSummary.warnings} warning${reportSummary.warnings === 1 ? "" : "s"}`, + tone: reportSummary.warnings > 0 ? "warning" : "muted", + }, + { text: `${reportSummary.skipped} already disabled`, tone: "muted" }, + ]), + ); + if (display.showPerAccountRows) { + logInfo(""); + for (const report of reports) { + const prefix = + report.outcome === "healthy" + ? "✓" + : report.outcome === "disabled-hard-failure" + ? "✗" + : report.outcome === "warning-soft-failure" + ? "!" + : "-"; + const tone = + report.outcome === "healthy" + ? "success" + : report.outcome === "disabled-hard-failure" + ? "danger" + : report.outcome === "warning-soft-failure" + ? "warning" + : "muted"; + logInfo( + `${deps.stylePromptText(prefix, tone)} ${deps.stylePromptText(`${report.index + 1}. ${report.label}`, "accent")} ${deps.stylePromptText("|", "muted")} ${deps.styleAccountDetailText(report.message, tone === "success" ? "muted" : tone)}`, + ); + } + } else { + logInfo(""); + logInfo( + deps.stylePromptText( + "Per-account lines are hidden in dashboard settings.", + "muted", + ), + ); + } + if (display.showRecommendations) { + logInfo(""); + if (recommendation.recommendedIndex !== null) { + const target = recommendation.recommendedIndex + 1; + logInfo( + `${deps.stylePromptText("Best next account:", "accent")} ${deps.stylePromptText(String(target), "success")}`, + ); + logInfo( + `${deps.stylePromptText("Why:", "accent")} ${deps.stylePromptText(recommendation.reason, "muted")}`, + ); + if (recommendation.recommendedIndex !== activeIndex) { + logInfo( + `${deps.stylePromptText("Switch now with:", "accent")} codex auth switch ${target}`, + ); + } + } else { + logInfo( + `${deps.stylePromptText("Note:", "accent")} ${deps.stylePromptText(recommendation.reason, "muted")}`, + ); + } + } + if (workingQuotaCache && quotaCacheChanged) + await deps.saveQuotaCache(workingQuotaCache); + if (changed && options.dryRun) + logInfo( + `\n${deps.stylePromptText("Preview only: no changes were saved.", "warning")}`, + ); + else if (changed) + logInfo(`\n${deps.stylePromptText("Saved updates.", "success")}`); + else logInfo(`\n${deps.stylePromptText("No changes were needed.", "muted")}`); + return 0; +} diff --git a/test/codex-manager-fix-command.test.ts b/test/codex-manager-fix-command.test.ts new file mode 100644 index 00000000..6ac130a2 --- /dev/null +++ b/test/codex-manager-fix-command.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from "vitest"; +import { + runFixCommand, + type FixCliOptions, + type FixCommandDeps, +} from "../lib/codex-manager/commands/fix.js"; +import type { AccountStorageV3 } from "../lib/storage.js"; + +function createStorage(): AccountStorageV3 { + return { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; +} + +function createDeps(overrides: Partial = {}): FixCommandDeps { + return { + setStoragePath: vi.fn(), + loadAccounts: vi.fn(async () => createStorage()), + parseFixArgs: vi.fn((args: string[]) => { + if (args.includes("--bad")) return { ok: false as const, message: "Unknown option: --bad" }; + return { ok: true as const, options: { dryRun: false, json: true, live: false, model: "gpt-5-codex" } satisfies FixCliOptions }; + }), + printFixUsage: vi.fn(), + loadQuotaCache: vi.fn(async () => null), + saveQuotaCache: vi.fn(async () => undefined), + cloneQuotaCacheData: vi.fn((cache) => structuredClone(cache)), + buildQuotaEmailFallbackState: vi.fn(() => new Map()), + updateQuotaCacheForAccount: vi.fn(() => false), + pruneUnsafeQuotaEmailCacheEntry: vi.fn(() => false), + resolveActiveIndex: vi.fn(() => 0), + hasUsableAccessToken: vi.fn(() => true), + fetchCodexQuotaSnapshot: vi.fn(async () => ({ status: 200, model: "gpt-5-codex", primary: {}, secondary: {} })), + formatCompactQuotaSnapshot: vi.fn(() => "5h 75%"), + normalizeFailureDetail: vi.fn((message) => message ?? "unknown"), + hasLikelyInvalidRefreshToken: vi.fn(() => false), + queuedRefresh: vi.fn(async () => ({ type: "success", access: "access-fix", refresh: "refresh-fix", expires: Date.now() + 60_000 })), + sanitizeEmail: vi.fn((email) => email), + extractAccountEmail: vi.fn(() => undefined), + extractAccountId: vi.fn(() => undefined), + applyTokenAccountIdentity: vi.fn(() => false), + isHardRefreshFailure: vi.fn(() => false), + evaluateForecastAccounts: vi.fn(() => []), + recommendForecastAccount: vi.fn(() => ({ recommendedIndex: null, reason: "none" })), + saveAccounts: vi.fn(async () => undefined), + formatAccountLabel: vi.fn((_account, index) => `${index + 1}. fix@example.com`), + stylePromptText: vi.fn((text) => text), + formatResultSummary: vi.fn((segments) => segments.map((segment) => segment.text).join(" | ")), + styleAccountDetailText: vi.fn((text) => text), + defaultDisplay: { + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }, + logInfo: vi.fn(), + logError: vi.fn(), + getNow: vi.fn(() => 1_000), + ...overrides, + }; +} + +describe("runFixCommand", () => { + it("prints usage for help", async () => { + const deps = createDeps(); + const result = await runFixCommand(["--help"], deps); + expect(result).toBe(0); + expect(deps.printFixUsage).toHaveBeenCalled(); + }); + + it("rejects invalid options", async () => { + const deps = createDeps(); + const result = await runFixCommand(["--bad"], deps); + expect(result).toBe(1); + expect(deps.logError).toHaveBeenCalledWith("Unknown option: --bad"); + }); + + it("prints json output for empty storage", async () => { + const deps = createDeps(); + const result = await runFixCommand([], deps); + expect(result).toBe(0); + expect(deps.logInfo).toHaveBeenCalledWith(expect.stringContaining("No accounts configured.")); + }); +}); From 231d9bbd1b06e29998afff2cb324a299b928084a Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 10:58:54 +0800 Subject: [PATCH 043/376] refactor: extract fix command --- lib/codex-manager.ts | 56 ++++++++++++++++++++------------------------ 1 file changed, 26 insertions(+), 30 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index b1c32ea7..ac2598ff 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -2834,35 +2834,6 @@ function applyDoctorFixes(storage: AccountStorageV3): { return { changed, actions }; } -async function runDoctor(args: string[]): Promise { - return runDoctorCommand(args, { - setStoragePath, - getStoragePath, - getCodexCliAuthPath, - getCodexCliConfigPath, - loadCodexCliState, - parseDoctorArgs, - printDoctorUsage, - loadAccounts, - applyDoctorFixes, - saveAccounts, - resolveActiveIndex, - evaluateForecastAccounts, - recommendForecastAccount, - sanitizeEmail, - extractAccountEmail, - extractAccountId, - hasPlaceholderEmail, - hasLikelyInvalidRefreshToken, - getDoctorRefreshTokenKey, - hasUsableAccessToken, - queuedRefresh, - normalizeFailureDetail, - applyTokenAccountIdentity, - setCodexCliActiveSelection, - }); -} - async function clearAccountsAndReset(): Promise { await clearAccounts(); } @@ -3600,7 +3571,32 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { return runFix(rest); } if (command === "doctor") { - return runDoctor(rest); + return runDoctorCommand(rest, { + setStoragePath, + getStoragePath, + getCodexCliAuthPath, + getCodexCliConfigPath, + loadCodexCliState, + parseDoctorArgs, + printDoctorUsage, + loadAccounts, + applyDoctorFixes, + saveAccounts, + resolveActiveIndex, + evaluateForecastAccounts, + recommendForecastAccount, + sanitizeEmail, + extractAccountEmail, + extractAccountId, + hasPlaceholderEmail, + hasLikelyInvalidRefreshToken, + getDoctorRefreshTokenKey, + hasUsableAccessToken, + queuedRefresh, + normalizeFailureDetail, + applyTokenAccountIdentity, + setCodexCliActiveSelection, + }); } console.error(`Unknown command: ${command}`); From 987fca24ddb1d34d51465d2a7241d6fba313011b Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 11:31:47 +0800 Subject: [PATCH 044/376] refactor: route fix through command module --- lib/codex-manager.ts | 101 +++++++++++++++++++++++++++---------------- 1 file changed, 64 insertions(+), 37 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index ac2598ff..7864539f 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -2667,41 +2667,6 @@ function upsertRecoveredFlaggedAccount( }; } -async function runFix(args: string[]): Promise { - return runFixCommand(args, { - setStoragePath, - loadAccounts, - parseFixArgs, - printFixUsage, - loadQuotaCache, - saveQuotaCache, - cloneQuotaCacheData, - buildQuotaEmailFallbackState, - updateQuotaCacheForAccount, - pruneUnsafeQuotaEmailCacheEntry, - resolveActiveIndex, - hasUsableAccessToken, - fetchCodexQuotaSnapshot, - formatCompactQuotaSnapshot, - normalizeFailureDetail, - hasLikelyInvalidRefreshToken, - queuedRefresh, - sanitizeEmail, - extractAccountEmail, - extractAccountId, - applyTokenAccountIdentity, - isHardRefreshFailure, - evaluateForecastAccounts, - recommendForecastAccount, - saveAccounts, - formatAccountLabel, - stylePromptText, - formatResultSummary, - styleAccountDetailText, - defaultDisplay: DEFAULT_DASHBOARD_DISPLAY_SETTINGS, - }); -} - interface DoctorFixAction { key: string; message: string; @@ -3022,7 +2987,38 @@ async function runAuthLogin(args: string[]): Promise { "Auto-Fix", "Checking and fixing common issues", async () => { - await runFix(["--live"]); + await runFixCommand(["--live"], { + setStoragePath, + loadAccounts, + parseFixArgs, + printFixUsage, + loadQuotaCache, + saveQuotaCache, + cloneQuotaCacheData, + buildQuotaEmailFallbackState, + updateQuotaCacheForAccount, + pruneUnsafeQuotaEmailCacheEntry, + resolveActiveIndex, + hasUsableAccessToken, + fetchCodexQuotaSnapshot, + formatCompactQuotaSnapshot, + normalizeFailureDetail, + hasLikelyInvalidRefreshToken, + queuedRefresh, + sanitizeEmail, + extractAccountEmail, + extractAccountId, + applyTokenAccountIdentity, + isHardRefreshFailure, + evaluateForecastAccounts, + recommendForecastAccount, + saveAccounts, + formatAccountLabel, + stylePromptText, + formatResultSummary, + styleAccountDetailText, + defaultDisplay: DEFAULT_DASHBOARD_DISPLAY_SETTINGS, + }); }, displaySettings, ); @@ -3568,7 +3564,38 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { }); } if (command === "fix") { - return runFix(rest); + return runFixCommand(rest, { + setStoragePath, + loadAccounts, + parseFixArgs, + printFixUsage, + loadQuotaCache, + saveQuotaCache, + cloneQuotaCacheData, + buildQuotaEmailFallbackState, + updateQuotaCacheForAccount, + pruneUnsafeQuotaEmailCacheEntry, + resolveActiveIndex, + hasUsableAccessToken, + fetchCodexQuotaSnapshot, + formatCompactQuotaSnapshot, + normalizeFailureDetail, + hasLikelyInvalidRefreshToken, + queuedRefresh, + sanitizeEmail, + extractAccountEmail, + extractAccountId, + applyTokenAccountIdentity, + isHardRefreshFailure, + evaluateForecastAccounts, + recommendForecastAccount, + saveAccounts, + formatAccountLabel, + stylePromptText, + formatResultSummary, + styleAccountDetailText, + defaultDisplay: DEFAULT_DASHBOARD_DISPLAY_SETTINGS, + }); } if (command === "doctor") { return runDoctorCommand(rest, { From b68f7d7a79109a90d8cce07894886a829f341fcb Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 11:35:15 +0800 Subject: [PATCH 045/376] fix(cli): snapshot doctor file existence checks --- lib/codex-manager/repair-commands.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/lib/codex-manager/repair-commands.ts b/lib/codex-manager/repair-commands.ts index 8f125536..134d338b 100644 --- a/lib/codex-manager/repair-commands.ts +++ b/lib/codex-manager/repair-commands.ts @@ -1520,6 +1520,7 @@ export async function runDoctor( setStoragePath(null); const storagePath = getStoragePath(); + const storageFileExists = existsSync(storagePath); const checks: DoctorCheck[] = []; const addCheck = (check: DoctorCheck): void => { checks.push(check); @@ -1527,14 +1528,14 @@ export async function runDoctor( addCheck({ key: "storage-file", - severity: existsSync(storagePath) ? "ok" : "warn", - message: existsSync(storagePath) + severity: storageFileExists ? "ok" : "warn", + message: storageFileExists ? "Account storage file found" : "Account storage file does not exist yet (first login pending)", details: storagePath, }); - if (existsSync(storagePath)) { + if (storageFileExists) { try { const stat = await fs.stat(storagePath); addCheck({ @@ -1555,19 +1556,21 @@ export async function runDoctor( const codexAuthPath = getCodexCliAuthPath(); const codexConfigPath = getCodexCliConfigPath(); + const codexAuthFileExists = existsSync(codexAuthPath); + const codexConfigFileExists = existsSync(codexConfigPath); let codexAuthEmail: string | undefined; let codexAuthAccountId: string | undefined; addCheck({ key: "codex-auth-file", - severity: existsSync(codexAuthPath) ? "ok" : "warn", - message: existsSync(codexAuthPath) + severity: codexAuthFileExists ? "ok" : "warn", + message: codexAuthFileExists ? "Codex auth file found" : "Codex auth file does not exist", details: codexAuthPath, }); - if (existsSync(codexAuthPath)) { + if (codexAuthFileExists) { try { const raw = await fs.readFile(codexAuthPath, "utf-8"); const parsed = JSON.parse(raw) as unknown; @@ -1617,15 +1620,15 @@ export async function runDoctor( addCheck({ key: "codex-config-file", - severity: existsSync(codexConfigPath) ? "ok" : "warn", - message: existsSync(codexConfigPath) + severity: codexConfigFileExists ? "ok" : "warn", + message: codexConfigFileExists ? "Codex config file found" : "Codex config file does not exist", details: codexConfigPath, }); let codexAuthStoreMode: string | undefined; - if (existsSync(codexConfigPath)) { + if (codexConfigFileExists) { try { const configRaw = await fs.readFile(codexConfigPath, "utf-8"); const match = configRaw.match(/^\s*cli_auth_credentials_store\s*=\s*"([^"]+)"\s*$/m); From b3afc2ac93a00effa30edfedc0f533770ef1f82a Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 11:35:15 +0800 Subject: [PATCH 046/376] fix(docs): tighten onboarding portal coverage --- docs/README.md | 2 +- test/codex-manager-cli.test.ts | 13 +++++++++++++ test/documentation.test.ts | 13 +++---------- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/docs/README.md b/docs/README.md index f1ff8bc9..cf33c195 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,7 +17,7 @@ Public documentation for `codex-multi-auth`. | Document | Focus | | --- | --- | -| [Daily-use landing page](index.md) | Daily-use landing page for common `codex auth ...` workflows | +| [Daily-use landing page](index.md) | Common `codex auth ...` workflows and quick-start guidance | | [faq.md](faq.md) | Short answers to common adoption questions | | [features.md](features.md) | User-facing capability map | | [configuration.md](configuration.md) | Stable defaults, precedence, and environment overrides | diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 930cf8fb..200f6c51 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -3001,9 +3001,11 @@ describe("codex manager cli commands", () => { close: vi.fn(), }; startLocalOAuthServerMock.mockResolvedValue(oauthServer); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + const renderedLogs = logSpy.mock.calls.flat().map((entry) => String(entry)); expect(exitCode).toBe(0); expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); @@ -3011,6 +3013,17 @@ describe("codex manager cli commands", () => { expect(storageState.activeIndex).toBe(1); expect(storageState.activeIndexByFamily.codex).toBe(1); expect(setCodexCliActiveSelectionMock).toHaveBeenCalledTimes(1); + expect(renderedLogs).toContain("Next steps:"); + expect(renderedLogs).toContain( + " codex auth status Check that the wrapper is active.", + ); + expect(renderedLogs).toContain( + " codex auth check Confirm your saved accounts look healthy.", + ); + expect(renderedLogs).toContain( + " codex auth list Review saved accounts before switching.", + ); + logSpy.mockRestore(); }); it("supports --manual login without launching a browser", async () => { diff --git a/test/documentation.test.ts b/test/documentation.test.ts index aa300513..8c0a6278 100644 --- a/test/documentation.test.ts +++ b/test/documentation.test.ts @@ -30,6 +30,9 @@ const userDocs = [ "docs/reference/error-contracts.md", "docs/reference/settings.md", "docs/reference/storage-paths.md", + "docs/releases/v1.1.10.md", + "docs/releases/v0.1.9.md", + "docs/releases/v0.1.8.md", "docs/releases/v0.1.7.md", "docs/releases/v0.1.6.md", "docs/releases/v0.1.5.md", @@ -260,16 +263,6 @@ describe("Documentation Integrity", () => { expect(manager).toContain( "codex auth fix [--dry-run] [--json] [--live] [--model ]", ); - expect(manager).toContain("Next steps:"); - expect(manager).toContain( - "codex auth status Check that the wrapper is active.", - ); - expect(manager).toContain( - "codex auth check Confirm your saved accounts look healthy.", - ); - expect(manager).toContain( - "codex auth list Review saved accounts before switching.", - ); expect(manager).toContain( "Missing index. Usage: codex auth switch ", ); From 51aab76a7f488dfd4ab02fc3cb2e8bd96b30bd5c Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 11:36:55 +0800 Subject: [PATCH 047/376] refactor: extract theme settings panel --- lib/codex-manager/settings-hub.ts | 142 ++---------------- lib/codex-manager/theme-settings-panel.ts | 166 ++++++++++++++++++++++ 2 files changed, 177 insertions(+), 131 deletions(-) create mode 100644 lib/codex-manager/theme-settings-panel.ts diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index fc999303..cddb75d8 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -28,9 +28,10 @@ import type { PluginConfig } from "../types.js"; import { ANSI } from "../ui/ansi.js"; import { UI_COPY } from "../ui/copy.js"; import { getUiRuntimeOptions, setUiRuntimeOptions } from "../ui/runtime.js"; -import { type MenuItem, select, type SelectOptions } from "../ui/select.js"; +import { type MenuItem, type SelectOptions, select } from "../ui/select.js"; import { getUnifiedSettingsPath } from "../unified-settings.js"; import { sleep } from "../utils.js"; +import { promptThemeSettingsPanel } from "./theme-settings-panel.js"; type DashboardDisplaySettingKey = | "menuShowStatusBadge" @@ -177,13 +178,6 @@ type BehaviorConfigAction = | { type: "save" } | { type: "cancel" }; -type ThemeConfigAction = - | { type: "set-palette"; palette: DashboardThemePreset } - | { type: "set-accent"; accent: DashboardAccentColor } - | { type: "reset" } - | { type: "save" } - | { type: "cancel" }; - type BackendToggleSettingKey = | "liveAccountSync" | "sessionAffinity" @@ -1997,129 +1991,15 @@ async function promptBehaviorSettings( async function promptThemeSettings( initial: DashboardDisplaySettings, ): Promise { - if (!input.isTTY || !output.isTTY) return null; - const baseline = cloneDashboardSettings(initial); - let draft = cloneDashboardSettings(initial); - let focus: ThemeConfigAction = { - type: "set-palette", - palette: draft.uiThemePreset ?? "green", - }; - while (true) { - const ui = getUiRuntimeOptions(); - const palette = draft.uiThemePreset ?? "green"; - const accent = draft.uiAccentColor ?? "green"; - const paletteItems: MenuItem[] = - THEME_PRESET_OPTIONS.map((candidate, index) => { - const color: MenuItem["color"] = - palette === candidate ? "green" : "yellow"; - return { - label: `${palette === candidate ? "[x]" : "[ ]"} ${index + 1}. ${candidate === "green" ? "Green base" : "Blue base"}`, - hint: - candidate === "green" - ? "High-contrast default." - : "Codex-style blue look.", - value: { type: "set-palette", palette: candidate }, - color, - }; - }); - const accentItems: MenuItem[] = ACCENT_COLOR_OPTIONS.map( - (candidate) => { - const color: MenuItem["color"] = - accent === candidate ? "green" : "yellow"; - return { - label: `${accent === candidate ? "[x]" : "[ ]"} ${candidate}`, - value: { type: "set-accent", accent: candidate }, - color, - }; - }, - ); - const items: MenuItem[] = [ - { - label: UI_COPY.settings.baseTheme, - value: { type: "cancel" }, - kind: "heading", - }, - ...paletteItems, - { label: "", value: { type: "cancel" }, separator: true }, - { - label: UI_COPY.settings.accentColor, - value: { type: "cancel" }, - kind: "heading", - }, - ...accentItems, - { label: "", value: { type: "cancel" }, separator: true }, - { - label: UI_COPY.settings.resetDefault, - value: { type: "reset" }, - color: "yellow", - }, - { - label: UI_COPY.settings.saveAndBack, - value: { type: "save" }, - color: "green", - }, - { - label: UI_COPY.settings.backNoSave, - value: { type: "cancel" }, - color: "red", - }, - ]; - const initialCursor = items.findIndex((item) => { - const value = item.value; - if (value.type !== focus.type) return false; - if (value.type === "set-palette" && focus.type === "set-palette") { - return value.palette === focus.palette; - } - if (value.type === "set-accent" && focus.type === "set-accent") { - return value.accent === focus.accent; - } - return true; - }); - const result = await select(items, { - message: UI_COPY.settings.themeTitle, - subtitle: UI_COPY.settings.themeSubtitle, - help: UI_COPY.settings.themeHelp, - clearScreen: true, - theme: ui.theme, - selectedEmphasis: "minimal", - initialCursor: initialCursor >= 0 ? initialCursor : undefined, - onCursorChange: ({ cursor }) => { - const item = items[cursor]; - if (item && !item.separator && item.kind !== "heading") { - focus = item.value; - } - }, - onInput: (raw) => { - const lower = raw.toLowerCase(); - if (lower === "q") return { type: "cancel" }; - if (lower === "s") return { type: "save" }; - if (lower === "r") return { type: "reset" }; - if (raw === "1") return { type: "set-palette", palette: "green" }; - if (raw === "2") return { type: "set-palette", palette: "blue" }; - return undefined; - }, - }); - if (!result || result.type === "cancel") { - applyUiThemeFromDashboardSettings(baseline); - return null; - } - if (result.type === "save") return draft; - if (result.type === "reset") { - draft = applyDashboardDefaultsForKeys(draft, THEME_PANEL_KEYS); - focus = { type: "set-palette", palette: draft.uiThemePreset ?? "green" }; - applyUiThemeFromDashboardSettings(draft); - continue; - } - if (result.type === "set-palette") { - draft = { ...draft, uiThemePreset: result.palette }; - focus = result; - applyUiThemeFromDashboardSettings(draft); - continue; - } - draft = { ...draft, uiAccentColor: result.accent }; - focus = result; - applyUiThemeFromDashboardSettings(draft); - } + return promptThemeSettingsPanel(initial, { + cloneDashboardSettings, + applyDashboardDefaultsForKeys, + applyUiThemeFromDashboardSettings, + THEME_PRESET_OPTIONS, + ACCENT_COLOR_OPTIONS, + THEME_PANEL_KEYS, + UI_COPY, + }); } function resolveFocusedBackendNumberKey( diff --git a/lib/codex-manager/theme-settings-panel.ts b/lib/codex-manager/theme-settings-panel.ts new file mode 100644 index 00000000..61259aed --- /dev/null +++ b/lib/codex-manager/theme-settings-panel.ts @@ -0,0 +1,166 @@ +import { stdin as input, stdout as output } from "node:process"; +import type { + DashboardAccentColor, + DashboardDisplaySettings, + DashboardThemePreset, +} from "../dashboard-settings.js"; +import type { UI_COPY } from "../ui/copy.js"; +import { getUiRuntimeOptions } from "../ui/runtime.js"; +import { type MenuItem, select } from "../ui/select.js"; + +export type ThemeConfigAction = + | { type: "set-palette"; palette: DashboardThemePreset } + | { type: "set-accent"; accent: DashboardAccentColor } + | { type: "reset" } + | { type: "save" } + | { type: "cancel" }; + +export interface ThemeSettingsPanelDeps { + cloneDashboardSettings: ( + settings: DashboardDisplaySettings, + ) => DashboardDisplaySettings; + applyDashboardDefaultsForKeys: ( + draft: DashboardDisplaySettings, + keys: readonly (keyof DashboardDisplaySettings)[], + ) => DashboardDisplaySettings; + applyUiThemeFromDashboardSettings: ( + settings: DashboardDisplaySettings, + ) => void; + THEME_PRESET_OPTIONS: readonly DashboardThemePreset[]; + ACCENT_COLOR_OPTIONS: readonly DashboardAccentColor[]; + THEME_PANEL_KEYS: readonly (keyof DashboardDisplaySettings)[]; + UI_COPY: typeof UI_COPY; +} + +export async function promptThemeSettingsPanel( + initial: DashboardDisplaySettings, + deps: ThemeSettingsPanelDeps, +): Promise { + if (!input.isTTY || !output.isTTY) return null; + const baseline = deps.cloneDashboardSettings(initial); + let draft = deps.cloneDashboardSettings(initial); + let focus: ThemeConfigAction = { + type: "set-palette", + palette: draft.uiThemePreset ?? "green", + }; + + while (true) { + const ui = getUiRuntimeOptions(); + const palette = draft.uiThemePreset ?? "green"; + const accent = draft.uiAccentColor ?? "green"; + const paletteItems: MenuItem[] = + deps.THEME_PRESET_OPTIONS.map((candidate, index) => { + const color: MenuItem["color"] = + palette === candidate ? "green" : "yellow"; + return { + label: `${palette === candidate ? "[x]" : "[ ]"} ${index + 1}. ${candidate === "green" ? "Green base" : "Blue base"}`, + hint: + candidate === "green" + ? "High-contrast default." + : "Codex-style blue look.", + value: { type: "set-palette", palette: candidate }, + color, + }; + }); + const accentItems: MenuItem[] = + deps.ACCENT_COLOR_OPTIONS.map((candidate) => { + const color: MenuItem["color"] = + accent === candidate ? "green" : "yellow"; + return { + label: `${accent === candidate ? "[x]" : "[ ]"} ${candidate}`, + value: { type: "set-accent", accent: candidate }, + color, + }; + }); + + const items: MenuItem[] = [ + { + label: deps.UI_COPY.settings.baseTheme, + value: { type: "cancel" }, + kind: "heading", + }, + ...paletteItems, + { label: "", value: { type: "cancel" }, separator: true }, + { + label: deps.UI_COPY.settings.accentColor, + value: { type: "cancel" }, + kind: "heading", + }, + ...accentItems, + { label: "", value: { type: "cancel" }, separator: true }, + { + label: deps.UI_COPY.settings.resetDefault, + value: { type: "reset" }, + color: "yellow", + }, + { + label: deps.UI_COPY.settings.saveAndBack, + value: { type: "save" }, + color: "green", + }, + { + label: deps.UI_COPY.settings.backNoSave, + value: { type: "cancel" }, + color: "red", + }, + ]; + + const initialCursor = items.findIndex((item) => { + const value = item.value; + if (value.type !== focus.type) return false; + if (value.type === "set-palette" && focus.type === "set-palette") { + return value.palette === focus.palette; + } + if (value.type === "set-accent" && focus.type === "set-accent") { + return value.accent === focus.accent; + } + return true; + }); + + const result = await select(items, { + message: deps.UI_COPY.settings.themeTitle, + subtitle: deps.UI_COPY.settings.themeSubtitle, + help: deps.UI_COPY.settings.themeHelp, + clearScreen: true, + theme: ui.theme, + selectedEmphasis: "minimal", + initialCursor: initialCursor >= 0 ? initialCursor : undefined, + onCursorChange: ({ cursor }) => { + const item = items[cursor]; + if (item && !item.separator && item.kind !== "heading") { + focus = item.value; + } + }, + onInput: (raw) => { + const lower = raw.toLowerCase(); + if (lower === "q") return { type: "cancel" }; + if (lower === "s") return { type: "save" }; + if (lower === "r") return { type: "reset" }; + if (raw === "1") return { type: "set-palette", palette: "green" }; + if (raw === "2") return { type: "set-palette", palette: "blue" }; + return undefined; + }, + }); + + if (!result || result.type === "cancel") { + deps.applyUiThemeFromDashboardSettings(baseline); + return null; + } + if (result.type === "save") return draft; + if (result.type === "reset") { + draft = deps.applyDashboardDefaultsForKeys(draft, deps.THEME_PANEL_KEYS); + focus = { type: "set-palette", palette: draft.uiThemePreset ?? "green" }; + deps.applyUiThemeFromDashboardSettings(draft); + continue; + } + if (result.type === "set-palette") { + draft = { ...draft, uiThemePreset: result.palette }; + focus = result; + deps.applyUiThemeFromDashboardSettings(draft); + continue; + } + draft = { ...draft, uiAccentColor: result.accent }; + focus = result; + deps.applyUiThemeFromDashboardSettings(draft); + } +} From 66d07c61d7aa26fed0316861aa700e54d242d638 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 11:41:54 +0800 Subject: [PATCH 048/376] refactor: extract behavior settings panel --- lib/codex-manager/behavior-settings-panel.ts | 217 +++++++++++++++++++ lib/codex-manager/settings-hub.ts | 206 +----------------- 2 files changed, 228 insertions(+), 195 deletions(-) create mode 100644 lib/codex-manager/behavior-settings-panel.ts diff --git a/lib/codex-manager/behavior-settings-panel.ts b/lib/codex-manager/behavior-settings-panel.ts new file mode 100644 index 00000000..658c3761 --- /dev/null +++ b/lib/codex-manager/behavior-settings-panel.ts @@ -0,0 +1,217 @@ +import { stdin as input, stdout as output } from "node:process"; +import type { DashboardDisplaySettings } from "../dashboard-settings.js"; +import type { UI_COPY } from "../ui/copy.js"; +import { getUiRuntimeOptions } from "../ui/runtime.js"; +import { type MenuItem, select } from "../ui/select.js"; + +export type BehaviorConfigAction = + | { type: "set-delay"; delayMs: number } + | { type: "toggle-pause" } + | { type: "toggle-menu-limit-fetch" } + | { type: "toggle-menu-fetch-status" } + | { type: "set-menu-quota-ttl"; ttlMs: number } + | { type: "reset" } + | { type: "save" } + | { type: "cancel" }; + +export interface BehaviorSettingsPanelDeps { + cloneDashboardSettings: ( + settings: DashboardDisplaySettings, + ) => DashboardDisplaySettings; + applyDashboardDefaultsForKeys: ( + draft: DashboardDisplaySettings, + keys: readonly (keyof DashboardDisplaySettings)[], + ) => DashboardDisplaySettings; + formatDelayLabel: (delayMs: number) => string; + formatMenuQuotaTtl: (ttlMs: number) => string; + AUTO_RETURN_OPTIONS_MS: readonly number[]; + MENU_QUOTA_TTL_OPTIONS_MS: readonly number[]; + BEHAVIOR_PANEL_KEYS: readonly (keyof DashboardDisplaySettings)[]; + UI_COPY: typeof UI_COPY; +} + +export async function promptBehaviorSettingsPanel( + initial: DashboardDisplaySettings, + deps: BehaviorSettingsPanelDeps, +): Promise { + if (!input.isTTY || !output.isTTY) return null; + const ui = getUiRuntimeOptions(); + let draft = deps.cloneDashboardSettings(initial); + let focus: BehaviorConfigAction = { + type: "set-delay", + delayMs: draft.actionAutoReturnMs ?? 2_000, + }; + + while (true) { + const currentDelay = draft.actionAutoReturnMs ?? 2_000; + const pauseOnKey = draft.actionPauseOnKey ?? true; + const autoFetchLimits = draft.menuAutoFetchLimits ?? true; + const fetchStatusVisible = draft.menuShowFetchStatus ?? true; + const menuQuotaTtlMs = draft.menuQuotaTtlMs ?? 5 * 60_000; + const delayItems: MenuItem[] = + deps.AUTO_RETURN_OPTIONS_MS.map((delayMs) => { + const color: MenuItem["color"] = + currentDelay === delayMs ? "green" : "yellow"; + return { + label: `${currentDelay === delayMs ? "[x]" : "[ ]"} ${deps.formatDelayLabel(delayMs)}`, + hint: + delayMs === 1_000 + ? "Fastest loop for frequent actions." + : delayMs === 2_000 + ? "Balanced default for most users." + : "More time to read action output.", + value: { type: "set-delay", delayMs }, + color, + }; + }); + const pauseColor: MenuItem["color"] = pauseOnKey + ? "green" + : "yellow"; + const items: MenuItem[] = [ + { + label: deps.UI_COPY.settings.actionTiming, + value: { type: "cancel" }, + kind: "heading", + }, + ...delayItems, + { label: "", value: { type: "cancel" }, separator: true }, + { + label: `${pauseOnKey ? "[x]" : "[ ]"} Pause on key press`, + hint: "Press any key to stop auto-return.", + value: { type: "toggle-pause" }, + color: pauseColor, + }, + { + label: `${autoFetchLimits ? "[x]" : "[ ]"} Auto-fetch limits on menu open (5m cache)`, + hint: "Refreshes account limits automatically when opening the menu.", + value: { type: "toggle-menu-limit-fetch" }, + color: autoFetchLimits ? "green" : "yellow", + }, + { + label: `${fetchStatusVisible ? "[x]" : "[ ]"} Show limit refresh status`, + hint: "Shows background fetch progress like [2/7] in menu subtitle.", + value: { type: "toggle-menu-fetch-status" }, + color: fetchStatusVisible ? "green" : "yellow", + }, + { + label: `Limit cache TTL: ${deps.formatMenuQuotaTtl(menuQuotaTtlMs)}`, + hint: "How fresh cached quota data must be before refresh runs.", + value: { type: "set-menu-quota-ttl", ttlMs: menuQuotaTtlMs }, + color: "yellow", + }, + { label: "", value: { type: "cancel" }, separator: true }, + { + label: deps.UI_COPY.settings.resetDefault, + value: { type: "reset" }, + color: "yellow", + }, + { + label: deps.UI_COPY.settings.saveAndBack, + value: { type: "save" }, + color: "green", + }, + { + label: deps.UI_COPY.settings.backNoSave, + value: { type: "cancel" }, + color: "red", + }, + ]; + + const initialCursor = items.findIndex((item) => { + const value = item.value; + if (value.type !== focus.type) return false; + if (value.type === "set-delay" && focus.type === "set-delay") { + return value.delayMs === focus.delayMs; + } + return true; + }); + + const result = await select(items, { + message: deps.UI_COPY.settings.behaviorTitle, + subtitle: deps.UI_COPY.settings.behaviorSubtitle, + help: deps.UI_COPY.settings.behaviorHelp, + clearScreen: true, + theme: ui.theme, + selectedEmphasis: "minimal", + initialCursor: initialCursor >= 0 ? initialCursor : undefined, + onCursorChange: ({ cursor }) => { + const item = items[cursor]; + if (item && !item.separator && item.kind !== "heading") { + focus = item.value; + } + }, + onInput: (raw) => { + const lower = raw.toLowerCase(); + if (lower === "q") return { type: "cancel" }; + if (lower === "s") return { type: "save" }; + if (lower === "r") return { type: "reset" }; + if (lower === "p") return { type: "toggle-pause" }; + if (lower === "l") return { type: "toggle-menu-limit-fetch" }; + if (lower === "f") return { type: "toggle-menu-fetch-status" }; + if (lower === "t") + return { type: "set-menu-quota-ttl", ttlMs: menuQuotaTtlMs }; + const parsed = Number.parseInt(raw, 10); + if ( + Number.isFinite(parsed) && + parsed >= 1 && + parsed <= deps.AUTO_RETURN_OPTIONS_MS.length + ) { + const delayMs = deps.AUTO_RETURN_OPTIONS_MS[parsed - 1]; + if (typeof delayMs === "number") + return { type: "set-delay", delayMs }; + } + return undefined; + }, + }); + + if (!result || result.type === "cancel") return null; + if (result.type === "save") return draft; + if (result.type === "reset") { + draft = deps.applyDashboardDefaultsForKeys( + draft, + deps.BEHAVIOR_PANEL_KEYS, + ); + focus = { type: "set-delay", delayMs: draft.actionAutoReturnMs ?? 2_000 }; + continue; + } + if (result.type === "toggle-pause") { + draft = { ...draft, actionPauseOnKey: !(draft.actionPauseOnKey ?? true) }; + focus = result; + continue; + } + if (result.type === "toggle-menu-limit-fetch") { + draft = { + ...draft, + menuAutoFetchLimits: !(draft.menuAutoFetchLimits ?? true), + }; + focus = result; + continue; + } + if (result.type === "toggle-menu-fetch-status") { + draft = { + ...draft, + menuShowFetchStatus: !(draft.menuShowFetchStatus ?? true), + }; + focus = result; + continue; + } + if (result.type === "set-menu-quota-ttl") { + const currentIndex = deps.MENU_QUOTA_TTL_OPTIONS_MS.findIndex( + (value) => value === menuQuotaTtlMs, + ); + const nextIndex = + currentIndex < 0 + ? 0 + : (currentIndex + 1) % deps.MENU_QUOTA_TTL_OPTIONS_MS.length; + const nextTtl = + deps.MENU_QUOTA_TTL_OPTIONS_MS[nextIndex] ?? + deps.MENU_QUOTA_TTL_OPTIONS_MS[0] ?? + menuQuotaTtlMs; + draft = { ...draft, menuQuotaTtlMs: nextTtl }; + focus = { type: "set-menu-quota-ttl", ttlMs: nextTtl }; + continue; + } + draft = { ...draft, actionAutoReturnMs: result.delayMs }; + focus = result; + } +} diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index cddb75d8..b11b21d7 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -31,6 +31,7 @@ import { getUiRuntimeOptions, setUiRuntimeOptions } from "../ui/runtime.js"; import { type MenuItem, type SelectOptions, select } from "../ui/select.js"; import { getUnifiedSettingsPath } from "../unified-settings.js"; import { sleep } from "../utils.js"; +import { promptBehaviorSettingsPanel } from "./behavior-settings-panel.js"; import { promptThemeSettingsPanel } from "./theme-settings-panel.js"; type DashboardDisplaySettingKey = @@ -168,16 +169,6 @@ type StatuslineConfigAction = | { type: "save" } | { type: "cancel" }; -type BehaviorConfigAction = - | { type: "set-delay"; delayMs: number } - | { type: "toggle-pause" } - | { type: "toggle-menu-limit-fetch" } - | { type: "toggle-menu-fetch-status" } - | { type: "set-menu-quota-ttl"; ttlMs: number } - | { type: "reset" } - | { type: "save" } - | { type: "cancel" }; - type BackendToggleSettingKey = | "liveAccountSync" | "sessionAffinity" @@ -1801,191 +1792,16 @@ function formatDelayLabel(delayMs: number): string { async function promptBehaviorSettings( initial: DashboardDisplaySettings, ): Promise { - if (!input.isTTY || !output.isTTY) return null; - const ui = getUiRuntimeOptions(); - let draft = cloneDashboardSettings(initial); - let focus: BehaviorConfigAction = { - type: "set-delay", - delayMs: draft.actionAutoReturnMs ?? 2_000, - }; - - while (true) { - const currentDelay = draft.actionAutoReturnMs ?? 2_000; - const pauseOnKey = draft.actionPauseOnKey ?? true; - const autoFetchLimits = draft.menuAutoFetchLimits ?? true; - const fetchStatusVisible = draft.menuShowFetchStatus ?? true; - const menuQuotaTtlMs = draft.menuQuotaTtlMs ?? 5 * 60_000; - const delayItems: MenuItem[] = - AUTO_RETURN_OPTIONS_MS.map((delayMs) => { - const color: MenuItem["color"] = - currentDelay === delayMs ? "green" : "yellow"; - return { - label: `${currentDelay === delayMs ? "[x]" : "[ ]"} ${formatDelayLabel(delayMs)}`, - hint: - delayMs === 1_000 - ? "Fastest loop for frequent actions." - : delayMs === 2_000 - ? "Balanced default for most users." - : "More time to read action output.", - value: { type: "set-delay", delayMs }, - color, - }; - }); - const pauseColor: MenuItem["color"] = pauseOnKey - ? "green" - : "yellow"; - const items: MenuItem[] = [ - { - label: UI_COPY.settings.actionTiming, - value: { type: "cancel" }, - kind: "heading", - }, - ...delayItems, - { label: "", value: { type: "cancel" }, separator: true }, - { - label: `${pauseOnKey ? "[x]" : "[ ]"} Pause on key press`, - hint: "Press any key to stop auto-return.", - value: { type: "toggle-pause" }, - color: pauseColor, - }, - { - label: `${autoFetchLimits ? "[x]" : "[ ]"} Auto-fetch limits on menu open (5m cache)`, - hint: "Refreshes account limits automatically when opening the menu.", - value: { type: "toggle-menu-limit-fetch" }, - color: autoFetchLimits ? "green" : "yellow", - }, - { - label: `${fetchStatusVisible ? "[x]" : "[ ]"} Show limit refresh status`, - hint: "Shows background fetch progress like [2/7] in menu subtitle.", - value: { type: "toggle-menu-fetch-status" }, - color: fetchStatusVisible ? "green" : "yellow", - }, - { - label: `Limit cache TTL: ${formatMenuQuotaTtl(menuQuotaTtlMs)}`, - hint: "How fresh cached quota data must be before refresh runs.", - value: { type: "set-menu-quota-ttl", ttlMs: menuQuotaTtlMs }, - color: "yellow", - }, - { label: "", value: { type: "cancel" }, separator: true }, - { - label: UI_COPY.settings.resetDefault, - value: { type: "reset" }, - color: "yellow", - }, - { - label: UI_COPY.settings.saveAndBack, - value: { type: "save" }, - color: "green", - }, - { - label: UI_COPY.settings.backNoSave, - value: { type: "cancel" }, - color: "red", - }, - ]; - const initialCursor = items.findIndex((item) => { - const value = item.value; - if (value.type !== focus.type) return false; - if (value.type === "set-delay" && focus.type === "set-delay") { - return value.delayMs === focus.delayMs; - } - return true; - }); - - const result = await select(items, { - message: UI_COPY.settings.behaviorTitle, - subtitle: UI_COPY.settings.behaviorSubtitle, - help: UI_COPY.settings.behaviorHelp, - clearScreen: true, - theme: ui.theme, - selectedEmphasis: "minimal", - initialCursor: initialCursor >= 0 ? initialCursor : undefined, - onCursorChange: ({ cursor }) => { - const item = items[cursor]; - if (item && !item.separator && item.kind !== "heading") { - focus = item.value; - } - }, - onInput: (raw) => { - const lower = raw.toLowerCase(); - if (lower === "q") return { type: "cancel" }; - if (lower === "s") return { type: "save" }; - if (lower === "r") return { type: "reset" }; - if (lower === "p") return { type: "toggle-pause" }; - if (lower === "l") return { type: "toggle-menu-limit-fetch" }; - if (lower === "f") return { type: "toggle-menu-fetch-status" }; - if (lower === "t") - return { type: "set-menu-quota-ttl", ttlMs: menuQuotaTtlMs }; - const parsed = Number.parseInt(raw, 10); - if ( - Number.isFinite(parsed) && - parsed >= 1 && - parsed <= AUTO_RETURN_OPTIONS_MS.length - ) { - const delayMs = AUTO_RETURN_OPTIONS_MS[parsed - 1]; - if (typeof delayMs === "number") - return { type: "set-delay", delayMs }; - } - return undefined; - }, - }); - - if (!result || result.type === "cancel") return null; - if (result.type === "save") return draft; - if (result.type === "reset") { - draft = applyDashboardDefaultsForKeys(draft, BEHAVIOR_PANEL_KEYS); - focus = { type: "set-delay", delayMs: draft.actionAutoReturnMs ?? 2_000 }; - continue; - } - if (result.type === "toggle-pause") { - draft = { - ...draft, - actionPauseOnKey: !(draft.actionPauseOnKey ?? true), - }; - focus = result; - continue; - } - if (result.type === "toggle-menu-limit-fetch") { - draft = { - ...draft, - menuAutoFetchLimits: !(draft.menuAutoFetchLimits ?? true), - }; - focus = result; - continue; - } - if (result.type === "toggle-menu-fetch-status") { - draft = { - ...draft, - menuShowFetchStatus: !(draft.menuShowFetchStatus ?? true), - }; - focus = result; - continue; - } - if (result.type === "set-menu-quota-ttl") { - const currentIndex = MENU_QUOTA_TTL_OPTIONS_MS.findIndex( - (value) => value === menuQuotaTtlMs, - ); - const nextIndex = - currentIndex < 0 - ? 0 - : (currentIndex + 1) % MENU_QUOTA_TTL_OPTIONS_MS.length; - const nextTtl = - MENU_QUOTA_TTL_OPTIONS_MS[nextIndex] ?? - MENU_QUOTA_TTL_OPTIONS_MS[0] ?? - menuQuotaTtlMs; - draft = { - ...draft, - menuQuotaTtlMs: nextTtl, - }; - focus = { type: "set-menu-quota-ttl", ttlMs: nextTtl }; - continue; - } - draft = { - ...draft, - actionAutoReturnMs: result.delayMs, - }; - focus = result; - } + return promptBehaviorSettingsPanel(initial, { + cloneDashboardSettings, + applyDashboardDefaultsForKeys, + formatDelayLabel, + formatMenuQuotaTtl, + AUTO_RETURN_OPTIONS_MS, + MENU_QUOTA_TTL_OPTIONS_MS, + BEHAVIOR_PANEL_KEYS, + UI_COPY, + }); } async function promptThemeSettings( From 17c3d6a7f3bc3124776bc8e3a94e4de60cf06cb4 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 11:46:33 +0800 Subject: [PATCH 049/376] refactor: extract dashboard display panel --- lib/codex-manager/dashboard-display-panel.ts | 258 +++++++++++++++++++ lib/codex-manager/settings-hub.ts | 227 +--------------- 2 files changed, 272 insertions(+), 213 deletions(-) create mode 100644 lib/codex-manager/dashboard-display-panel.ts diff --git a/lib/codex-manager/dashboard-display-panel.ts b/lib/codex-manager/dashboard-display-panel.ts new file mode 100644 index 00000000..06d85c5f --- /dev/null +++ b/lib/codex-manager/dashboard-display-panel.ts @@ -0,0 +1,258 @@ +import { stdin as input, stdout as output } from "node:process"; +import { + type DashboardAccountSortMode, + type DashboardDisplaySettings, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS, +} from "../dashboard-settings.js"; +import type { UI_COPY } from "../ui/copy.js"; +import { getUiRuntimeOptions } from "../ui/runtime.js"; +import { type MenuItem, select } from "../ui/select.js"; + +export type DashboardDisplaySettingKey = + | "menuShowStatusBadge" + | "menuShowCurrentBadge" + | "menuShowLastUsed" + | "menuShowQuotaSummary" + | "menuShowQuotaCooldown" + | "menuShowDetailsForUnselectedRows" + | "menuShowFetchStatus" + | "menuHighlightCurrentRow" + | "menuSortEnabled" + | "menuSortPinCurrent" + | "menuSortQuickSwitchVisibleRow"; + +export interface DashboardDisplaySettingOption { + key: DashboardDisplaySettingKey; + label: string; + description: string; +} + +export type DashboardConfigAction = + | { type: "toggle"; key: DashboardDisplaySettingKey } + | { type: "cycle-sort-mode" } + | { type: "cycle-layout-mode" } + | { type: "reset" } + | { type: "save" } + | { type: "cancel" }; + +export interface DashboardDisplayPanelDeps { + cloneDashboardSettings: ( + settings: DashboardDisplaySettings, + ) => DashboardDisplaySettings; + buildAccountListPreview: ( + settings: DashboardDisplaySettings, + ui: ReturnType, + focusKey: DashboardDisplaySettingKey | "menuSortMode" | "menuLayoutMode", + ) => { label: string; hint?: string }; + formatDashboardSettingState: (enabled: boolean) => string; + formatMenuSortMode: (mode: DashboardAccountSortMode) => string; + resolveMenuLayoutMode: ( + settings?: DashboardDisplaySettings, + ) => "compact-details" | "expanded-rows"; + formatMenuLayoutMode: (mode: "compact-details" | "expanded-rows") => string; + applyDashboardDefaultsForKeys: ( + draft: DashboardDisplaySettings, + keys: readonly (keyof DashboardDisplaySettings)[], + ) => DashboardDisplaySettings; + DASHBOARD_DISPLAY_OPTIONS: readonly DashboardDisplaySettingOption[]; + ACCOUNT_LIST_PANEL_KEYS: readonly (keyof DashboardDisplaySettings)[]; + UI_COPY: typeof UI_COPY; +} + +export async function promptDashboardDisplayPanel( + initial: DashboardDisplaySettings, + deps: DashboardDisplayPanelDeps, +): Promise { + if (!input.isTTY || !output.isTTY) return null; + + const ui = getUiRuntimeOptions(); + let draft = deps.cloneDashboardSettings(initial); + let focusKey: DashboardDisplaySettingKey | "menuSortMode" | "menuLayoutMode" = + deps.DASHBOARD_DISPLAY_OPTIONS[0]?.key ?? "menuShowStatusBadge"; + + while (true) { + const preview = deps.buildAccountListPreview(draft, ui, focusKey); + const optionItems: MenuItem[] = + deps.DASHBOARD_DISPLAY_OPTIONS.map((option, index) => { + const enabled = draft[option.key] ?? true; + return { + label: `${deps.formatDashboardSettingState(enabled)} ${index + 1}. ${option.label}`, + hint: option.description, + value: { type: "toggle", key: option.key }, + color: enabled ? "green" : "yellow", + }; + }); + const sortMode = + draft.menuSortMode ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? + "ready-first"; + const sortModeItem: MenuItem = { + label: `Sort mode: ${deps.formatMenuSortMode(sortMode)}`, + hint: "Applies when smart sort is enabled.", + value: { type: "cycle-sort-mode" }, + color: sortMode === "ready-first" ? "green" : "yellow", + }; + const layoutMode = deps.resolveMenuLayoutMode(draft); + const layoutModeItem: MenuItem = { + label: `Layout: ${deps.formatMenuLayoutMode(layoutMode)}`, + hint: "Compact shows one-line rows with a selected details pane.", + value: { type: "cycle-layout-mode" }, + color: layoutMode === "compact-details" ? "green" : "yellow", + }; + const items: MenuItem[] = [ + { + label: deps.UI_COPY.settings.previewHeading, + value: { type: "cancel" }, + kind: "heading", + }, + { + label: preview.label, + hint: preview.hint, + value: { type: "cancel" }, + color: "green", + disabled: true, + hideUnavailableSuffix: true, + }, + { label: "", value: { type: "cancel" }, separator: true }, + { + label: deps.UI_COPY.settings.displayHeading, + value: { type: "cancel" }, + kind: "heading", + }, + ...optionItems, + sortModeItem, + layoutModeItem, + { label: "", value: { type: "cancel" }, separator: true }, + { + label: deps.UI_COPY.settings.resetDefault, + value: { type: "reset" }, + color: "yellow", + }, + { + label: deps.UI_COPY.settings.saveAndBack, + value: { type: "save" }, + color: "green", + }, + { + label: deps.UI_COPY.settings.backNoSave, + value: { type: "cancel" }, + color: "red", + }, + ]; + + const initialCursor = items.findIndex( + (item) => + (item.value.type === "toggle" && item.value.key === focusKey) || + (item.value.type === "cycle-sort-mode" && + focusKey === "menuSortMode") || + (item.value.type === "cycle-layout-mode" && + focusKey === "menuLayoutMode"), + ); + + const updateFocusedPreview = (cursor: number) => { + const focusedItem = items[cursor]; + const focused = + focusedItem?.value.type === "toggle" + ? focusedItem.value.key + : focusedItem?.value.type === "cycle-sort-mode" + ? "menuSortMode" + : focusedItem?.value.type === "cycle-layout-mode" + ? "menuLayoutMode" + : focusKey; + const nextPreview = deps.buildAccountListPreview(draft, ui, focused); + const previewItem = items[1]; + if (!previewItem) return; + previewItem.label = nextPreview.label; + previewItem.hint = nextPreview.hint; + }; + + const result = await select(items, { + message: deps.UI_COPY.settings.accountListTitle, + subtitle: deps.UI_COPY.settings.accountListSubtitle, + help: deps.UI_COPY.settings.accountListHelp, + clearScreen: true, + theme: ui.theme, + selectedEmphasis: "minimal", + initialCursor: initialCursor >= 0 ? initialCursor : undefined, + onCursorChange: ({ cursor }) => { + const focusedItem = items[cursor]; + if (focusedItem?.value.type === "toggle") + focusKey = focusedItem.value.key; + else if (focusedItem?.value.type === "cycle-sort-mode") + focusKey = "menuSortMode"; + else if (focusedItem?.value.type === "cycle-layout-mode") + focusKey = "menuLayoutMode"; + updateFocusedPreview(cursor); + }, + onInput: (raw) => { + const lower = raw.toLowerCase(); + if (lower === "q") return { type: "cancel" }; + if (lower === "s") return { type: "save" }; + if (lower === "r") return { type: "reset" }; + if (lower === "m") return { type: "cycle-sort-mode" }; + if (lower === "l") return { type: "cycle-layout-mode" }; + const parsed = Number.parseInt(raw, 10); + if ( + Number.isFinite(parsed) && + parsed >= 1 && + parsed <= deps.DASHBOARD_DISPLAY_OPTIONS.length + ) { + const target = deps.DASHBOARD_DISPLAY_OPTIONS[parsed - 1]; + if (target) return { type: "toggle", key: target.key }; + } + if (parsed === deps.DASHBOARD_DISPLAY_OPTIONS.length + 1) + return { type: "cycle-sort-mode" }; + if (parsed === deps.DASHBOARD_DISPLAY_OPTIONS.length + 2) + return { type: "cycle-layout-mode" }; + return undefined; + }, + }); + + if (!result || result.type === "cancel") return null; + if (result.type === "save") return draft; + if (result.type === "reset") { + draft = deps.applyDashboardDefaultsForKeys( + draft, + deps.ACCOUNT_LIST_PANEL_KEYS, + ); + focusKey = deps.DASHBOARD_DISPLAY_OPTIONS[0]?.key ?? focusKey; + continue; + } + if (result.type === "cycle-sort-mode") { + const currentMode = + draft.menuSortMode ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? + "ready-first"; + const nextMode: DashboardAccountSortMode = + currentMode === "ready-first" ? "manual" : "ready-first"; + draft = { + ...draft, + menuSortMode: nextMode, + menuSortEnabled: + nextMode === "ready-first" + ? true + : (draft.menuSortEnabled ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? + true), + }; + focusKey = "menuSortMode"; + continue; + } + if (result.type === "cycle-layout-mode") { + const currentLayout = deps.resolveMenuLayoutMode(draft); + const nextLayout = + currentLayout === "compact-details" + ? "expanded-rows" + : "compact-details"; + draft = { + ...draft, + menuLayoutMode: nextLayout, + menuShowDetailsForUnselectedRows: nextLayout === "expanded-rows", + }; + focusKey = "menuLayoutMode"; + continue; + } + focusKey = result.key; + draft = { ...draft, [result.key]: !draft[result.key] }; + } +} diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index b11b21d7..c122d515 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -32,6 +32,7 @@ import { type MenuItem, type SelectOptions, select } from "../ui/select.js"; import { getUnifiedSettingsPath } from "../unified-settings.js"; import { sleep } from "../utils.js"; import { promptBehaviorSettingsPanel } from "./behavior-settings-panel.js"; +import { promptDashboardDisplayPanel } from "./dashboard-display-panel.js"; import { promptThemeSettingsPanel } from "./theme-settings-panel.js"; type DashboardDisplaySettingKey = @@ -153,14 +154,6 @@ type PreviewFocusKey = | "menuLayoutMode" | null; -type DashboardConfigAction = - | { type: "toggle"; key: DashboardDisplaySettingKey } - | { type: "cycle-sort-mode" } - | { type: "cycle-layout-mode" } - | { type: "reset" } - | { type: "save" } - | { type: "cancel" }; - type StatuslineConfigAction = | { type: "toggle"; key: DashboardStatuslineField } | { type: "move-up"; key: DashboardStatuslineField } @@ -1320,211 +1313,19 @@ const __testOnly = { async function promptDashboardDisplaySettings( initial: DashboardDisplaySettings, ): Promise { - if (!input.isTTY || !output.isTTY) { - return null; - } - - const ui = getUiRuntimeOptions(); - let draft = cloneDashboardSettings(initial); - let focusKey: DashboardDisplaySettingKey | "menuSortMode" | "menuLayoutMode" = - DASHBOARD_DISPLAY_OPTIONS[0]?.key ?? "menuShowStatusBadge"; - while (true) { - const preview = buildAccountListPreview(draft, ui, focusKey); - const optionItems: MenuItem[] = - DASHBOARD_DISPLAY_OPTIONS.map((option, index) => { - const enabled = draft[option.key] ?? true; - const label = `${formatDashboardSettingState(enabled)} ${index + 1}. ${option.label}`; - const color: MenuItem["color"] = enabled - ? "green" - : "yellow"; - return { - label, - hint: option.description, - value: { type: "toggle", key: option.key } as DashboardConfigAction, - color, - }; - }); - const sortMode = - draft.menuSortMode ?? - DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? - "ready-first"; - const sortModeItem: MenuItem = { - label: `Sort mode: ${formatMenuSortMode(sortMode)}`, - hint: "Applies when smart sort is enabled.", - value: { type: "cycle-sort-mode" }, - color: sortMode === "ready-first" ? "green" : "yellow", - }; - const layoutMode = resolveMenuLayoutMode(draft); - const layoutModeItem: MenuItem = { - label: `Layout: ${formatMenuLayoutMode(layoutMode)}`, - hint: "Compact shows one-line rows with a selected details pane.", - value: { type: "cycle-layout-mode" }, - color: layoutMode === "compact-details" ? "green" : "yellow", - }; - const items: MenuItem[] = [ - { - label: UI_COPY.settings.previewHeading, - value: { type: "cancel" }, - kind: "heading", - }, - { - label: preview.label, - hint: preview.hint, - value: { type: "cancel" }, - color: "green", - disabled: true, - hideUnavailableSuffix: true, - }, - { label: "", value: { type: "cancel" }, separator: true }, - { - label: UI_COPY.settings.displayHeading, - value: { type: "cancel" }, - kind: "heading", - }, - ...optionItems, - sortModeItem, - layoutModeItem, - { label: "", value: { type: "cancel" }, separator: true }, - { - label: UI_COPY.settings.resetDefault, - value: { type: "reset" }, - color: "yellow", - }, - { - label: UI_COPY.settings.saveAndBack, - value: { type: "save" }, - color: "green", - }, - { - label: UI_COPY.settings.backNoSave, - value: { type: "cancel" }, - color: "red", - }, - ]; - const initialCursor = items.findIndex( - (item) => - (item.value.type === "toggle" && item.value.key === focusKey) || - (item.value.type === "cycle-sort-mode" && - focusKey === "menuSortMode") || - (item.value.type === "cycle-layout-mode" && - focusKey === "menuLayoutMode"), - ); - - const updateFocusedPreview = (cursor: number) => { - const focusedItem = items[cursor]; - const focusedKey = - focusedItem?.value.type === "toggle" - ? focusedItem.value.key - : focusedItem?.value.type === "cycle-sort-mode" - ? "menuSortMode" - : focusedItem?.value.type === "cycle-layout-mode" - ? "menuLayoutMode" - : focusKey; - const nextPreview = buildAccountListPreview(draft, ui, focusedKey); - const previewItem = items[1]; - if (!previewItem) return; - previewItem.label = nextPreview.label; - previewItem.hint = nextPreview.hint; - }; - - const result = await select(items, { - message: UI_COPY.settings.accountListTitle, - subtitle: UI_COPY.settings.accountListSubtitle, - help: UI_COPY.settings.accountListHelp, - clearScreen: true, - theme: ui.theme, - selectedEmphasis: "minimal", - initialCursor: initialCursor >= 0 ? initialCursor : undefined, - onCursorChange: ({ cursor }) => { - const focusedItem = items[cursor]; - if (focusedItem?.value.type === "toggle") { - focusKey = focusedItem.value.key; - } else if (focusedItem?.value.type === "cycle-sort-mode") { - focusKey = "menuSortMode"; - } else if (focusedItem?.value.type === "cycle-layout-mode") { - focusKey = "menuLayoutMode"; - } - updateFocusedPreview(cursor); - }, - onInput: (raw) => { - const lower = raw.toLowerCase(); - if (lower === "q") return { type: "cancel" }; - if (lower === "s") return { type: "save" }; - if (lower === "r") return { type: "reset" }; - if (lower === "m") return { type: "cycle-sort-mode" }; - if (lower === "l") return { type: "cycle-layout-mode" }; - const parsed = Number.parseInt(raw, 10); - if ( - Number.isFinite(parsed) && - parsed >= 1 && - parsed <= DASHBOARD_DISPLAY_OPTIONS.length - ) { - const target = DASHBOARD_DISPLAY_OPTIONS[parsed - 1]; - if (target) { - return { type: "toggle", key: target.key }; - } - } - if (parsed === DASHBOARD_DISPLAY_OPTIONS.length + 1) { - return { type: "cycle-sort-mode" }; - } - if (parsed === DASHBOARD_DISPLAY_OPTIONS.length + 2) { - return { type: "cycle-layout-mode" }; - } - return undefined; - }, - }); - - if (!result || result.type === "cancel") { - return null; - } - if (result.type === "save") { - return draft; - } - if (result.type === "reset") { - draft = applyDashboardDefaultsForKeys(draft, ACCOUNT_LIST_PANEL_KEYS); - focusKey = DASHBOARD_DISPLAY_OPTIONS[0]?.key ?? focusKey; - continue; - } - if (result.type === "cycle-sort-mode") { - const currentMode = - draft.menuSortMode ?? - DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? - "ready-first"; - const nextMode: DashboardAccountSortMode = - currentMode === "ready-first" ? "manual" : "ready-first"; - draft = { - ...draft, - menuSortMode: nextMode, - menuSortEnabled: - nextMode === "ready-first" - ? true - : (draft.menuSortEnabled ?? - DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? - true), - }; - focusKey = "menuSortMode"; - continue; - } - if (result.type === "cycle-layout-mode") { - const currentLayout = resolveMenuLayoutMode(draft); - const nextLayout = - currentLayout === "compact-details" - ? "expanded-rows" - : "compact-details"; - draft = { - ...draft, - menuLayoutMode: nextLayout, - menuShowDetailsForUnselectedRows: nextLayout === "expanded-rows", - }; - focusKey = "menuLayoutMode"; - continue; - } - focusKey = result.key; - draft = { - ...draft, - [result.key]: !draft[result.key], - }; - } + return promptDashboardDisplayPanel(initial, { + cloneDashboardSettings, + buildAccountListPreview, + formatDashboardSettingState, + formatMenuSortMode, + resolveMenuLayoutMode: (settings) => + resolveMenuLayoutMode(settings ?? DEFAULT_DASHBOARD_DISPLAY_SETTINGS), + formatMenuLayoutMode, + applyDashboardDefaultsForKeys, + DASHBOARD_DISPLAY_OPTIONS, + ACCOUNT_LIST_PANEL_KEYS, + UI_COPY, + }); } async function configureDashboardDisplaySettings( From f63e9496fa690dbefd841c372c8289f27e84031f Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 11:50:02 +0800 Subject: [PATCH 050/376] refactor: extract statusline settings panel --- lib/codex-manager/settings-hub.ts | 207 +--------------- .../statusline-settings-panel.ts | 233 ++++++++++++++++++ 2 files changed, 245 insertions(+), 195 deletions(-) create mode 100644 lib/codex-manager/statusline-settings-panel.ts diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index c122d515..30417ec3 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -33,6 +33,7 @@ import { getUnifiedSettingsPath } from "../unified-settings.js"; import { sleep } from "../utils.js"; import { promptBehaviorSettingsPanel } from "./behavior-settings-panel.js"; import { promptDashboardDisplayPanel } from "./dashboard-display-panel.js"; +import { promptStatuslineSettingsPanel } from "./statusline-settings-panel.js"; import { promptThemeSettingsPanel } from "./theme-settings-panel.js"; type DashboardDisplaySettingKey = @@ -154,14 +155,6 @@ type PreviewFocusKey = | "menuLayoutMode" | null; -type StatuslineConfigAction = - | { type: "toggle"; key: DashboardStatuslineField } - | { type: "move-up"; key: DashboardStatuslineField } - | { type: "move-down"; key: DashboardStatuslineField } - | { type: "reset" } - | { type: "save" } - | { type: "cancel" }; - type BackendToggleSettingKey = | "liveAccountSync" | "sessionAffinity" @@ -1372,193 +1365,17 @@ function reorderField( async function promptStatuslineSettings( initial: DashboardDisplaySettings, ): Promise { - if (!input.isTTY || !output.isTTY) { - return null; - } - - const ui = getUiRuntimeOptions(); - let draft = cloneDashboardSettings(initial); - let focusKey: DashboardStatuslineField = - draft.menuStatuslineFields?.[0] ?? "last-used"; - while (true) { - const preview = buildAccountListPreview(draft, ui, focusKey); - const selectedSet = new Set( - normalizeStatuslineFields(draft.menuStatuslineFields), - ); - const ordered = normalizeStatuslineFields(draft.menuStatuslineFields); - const orderMap = new Map(); - for (let index = 0; index < ordered.length; index += 1) { - const key = ordered[index]; - if (key) orderMap.set(key, index + 1); - } - - const optionItems: MenuItem[] = - STATUSLINE_FIELD_OPTIONS.map((option, index) => { - const enabled = selectedSet.has(option.key); - const rank = orderMap.get(option.key); - const label = `${formatDashboardSettingState(enabled)} ${index + 1}. ${option.label}${rank ? ` (order ${rank})` : ""}`; - return { - label, - hint: option.description, - value: { type: "toggle", key: option.key }, - color: enabled ? "green" : "yellow", - }; - }); - - const items: MenuItem[] = [ - { - label: UI_COPY.settings.previewHeading, - value: { type: "cancel" }, - kind: "heading", - }, - { - label: preview.label, - hint: preview.hint, - value: { type: "cancel" }, - color: "green", - disabled: true, - hideUnavailableSuffix: true, - }, - { label: "", value: { type: "cancel" }, separator: true }, - { - label: UI_COPY.settings.displayHeading, - value: { type: "cancel" }, - kind: "heading", - }, - ...optionItems, - { label: "", value: { type: "cancel" }, separator: true }, - { - label: UI_COPY.settings.moveUp, - value: { type: "move-up", key: focusKey }, - color: "green", - }, - { - label: UI_COPY.settings.moveDown, - value: { type: "move-down", key: focusKey }, - color: "green", - }, - { label: "", value: { type: "cancel" }, separator: true }, - { - label: UI_COPY.settings.resetDefault, - value: { type: "reset" }, - color: "yellow", - }, - { - label: UI_COPY.settings.saveAndBack, - value: { type: "save" }, - color: "green", - }, - { - label: UI_COPY.settings.backNoSave, - value: { type: "cancel" }, - color: "red", - }, - ]; - - const initialCursor = items.findIndex( - (item) => item.value.type === "toggle" && item.value.key === focusKey, - ); - - const updateFocusedPreview = (cursor: number) => { - const focusedItem = items[cursor]; - const focusedKey = - focusedItem?.value.type === "toggle" ? focusedItem.value.key : focusKey; - const nextPreview = buildAccountListPreview(draft, ui, focusedKey); - const previewItem = items[1]; - if (!previewItem) return; - previewItem.label = nextPreview.label; - previewItem.hint = nextPreview.hint; - }; - - const result = await select(items, { - message: UI_COPY.settings.summaryTitle, - subtitle: UI_COPY.settings.summarySubtitle, - help: UI_COPY.settings.summaryHelp, - clearScreen: true, - theme: ui.theme, - selectedEmphasis: "minimal", - initialCursor: initialCursor >= 0 ? initialCursor : undefined, - onCursorChange: ({ cursor }) => { - const focusedItem = items[cursor]; - if (focusedItem?.value.type === "toggle") { - focusKey = focusedItem.value.key; - } - updateFocusedPreview(cursor); - }, - onInput: (raw) => { - const lower = raw.toLowerCase(); - if (lower === "q") return { type: "cancel" }; - if (lower === "s") return { type: "save" }; - if (lower === "r") return { type: "reset" }; - if (lower === "[") return { type: "move-up", key: focusKey }; - if (lower === "]") return { type: "move-down", key: focusKey }; - const parsed = Number.parseInt(raw, 10); - if ( - Number.isFinite(parsed) && - parsed >= 1 && - parsed <= STATUSLINE_FIELD_OPTIONS.length - ) { - const target = STATUSLINE_FIELD_OPTIONS[parsed - 1]; - if (target) { - return { type: "toggle", key: target.key }; - } - } - return undefined; - }, - }); - - if (!result || result.type === "cancel") { - return null; - } - if (result.type === "save") { - return draft; - } - if (result.type === "reset") { - draft = applyDashboardDefaultsForKeys(draft, STATUSLINE_PANEL_KEYS); - focusKey = draft.menuStatuslineFields?.[0] ?? "last-used"; - continue; - } - if (result.type === "move-up") { - draft = { - ...draft, - menuStatuslineFields: reorderField( - normalizeStatuslineFields(draft.menuStatuslineFields), - result.key, - -1, - ), - }; - focusKey = result.key; - continue; - } - if (result.type === "move-down") { - draft = { - ...draft, - menuStatuslineFields: reorderField( - normalizeStatuslineFields(draft.menuStatuslineFields), - result.key, - 1, - ), - }; - focusKey = result.key; - continue; - } - - focusKey = result.key; - const fields = normalizeStatuslineFields(draft.menuStatuslineFields); - const isEnabled = fields.includes(result.key); - if (isEnabled) { - const next = fields.filter((field) => field !== result.key); - draft = { - ...draft, - menuStatuslineFields: next.length > 0 ? next : [result.key], - }; - } else { - draft = { - ...draft, - menuStatuslineFields: [...fields, result.key], - }; - } - } + return promptStatuslineSettingsPanel(initial, { + cloneDashboardSettings, + buildAccountListPreview, + normalizeStatuslineFields, + formatDashboardSettingState, + reorderField, + applyDashboardDefaultsForKeys, + STATUSLINE_FIELD_OPTIONS, + STATUSLINE_PANEL_KEYS, + UI_COPY, + }); } async function configureStatuslineSettings( diff --git a/lib/codex-manager/statusline-settings-panel.ts b/lib/codex-manager/statusline-settings-panel.ts new file mode 100644 index 00000000..9018d91c --- /dev/null +++ b/lib/codex-manager/statusline-settings-panel.ts @@ -0,0 +1,233 @@ +import { stdin as input, stdout as output } from "node:process"; +import type { + DashboardDisplaySettings, + DashboardStatuslineField, +} from "../dashboard-settings.js"; +import type { UI_COPY } from "../ui/copy.js"; +import { getUiRuntimeOptions } from "../ui/runtime.js"; +import { type MenuItem, select } from "../ui/select.js"; + +export type StatuslineConfigAction = + | { type: "toggle"; key: DashboardStatuslineField } + | { type: "move-up"; key: DashboardStatuslineField } + | { type: "move-down"; key: DashboardStatuslineField } + | { type: "reset" } + | { type: "save" } + | { type: "cancel" }; + +export interface StatuslineFieldOption { + key: DashboardStatuslineField; + label: string; + description: string; +} + +export interface StatuslineSettingsPanelDeps { + cloneDashboardSettings: ( + settings: DashboardDisplaySettings, + ) => DashboardDisplaySettings; + buildAccountListPreview: ( + settings: DashboardDisplaySettings, + ui: ReturnType, + focusKey: DashboardStatuslineField, + ) => { label: string; hint?: string }; + normalizeStatuslineFields: ( + fields: DashboardDisplaySettings["menuStatuslineFields"], + ) => DashboardStatuslineField[]; + formatDashboardSettingState: (enabled: boolean) => string; + reorderField: ( + fields: DashboardStatuslineField[], + key: DashboardStatuslineField, + direction: -1 | 1, + ) => DashboardStatuslineField[]; + applyDashboardDefaultsForKeys: ( + draft: DashboardDisplaySettings, + keys: readonly (keyof DashboardDisplaySettings)[], + ) => DashboardDisplaySettings; + STATUSLINE_FIELD_OPTIONS: readonly StatuslineFieldOption[]; + STATUSLINE_PANEL_KEYS: readonly (keyof DashboardDisplaySettings)[]; + UI_COPY: typeof UI_COPY; +} + +export async function promptStatuslineSettingsPanel( + initial: DashboardDisplaySettings, + deps: StatuslineSettingsPanelDeps, +): Promise { + if (!input.isTTY || !output.isTTY) return null; + + const ui = getUiRuntimeOptions(); + let draft = deps.cloneDashboardSettings(initial); + let focusKey: DashboardStatuslineField = + draft.menuStatuslineFields?.[0] ?? "last-used"; + + while (true) { + const preview = deps.buildAccountListPreview(draft, ui, focusKey); + const selectedSet = new Set( + deps.normalizeStatuslineFields(draft.menuStatuslineFields), + ); + const ordered = deps.normalizeStatuslineFields(draft.menuStatuslineFields); + const orderMap = new Map(); + for (let index = 0; index < ordered.length; index += 1) { + const key = ordered[index]; + if (key) orderMap.set(key, index + 1); + } + + const optionItems: MenuItem[] = + deps.STATUSLINE_FIELD_OPTIONS.map((option, index) => { + const enabled = selectedSet.has(option.key); + const rank = orderMap.get(option.key); + return { + label: `${deps.formatDashboardSettingState(enabled)} ${index + 1}. ${option.label}${rank ? ` (order ${rank})` : ""}`, + hint: option.description, + value: { type: "toggle", key: option.key }, + color: enabled ? "green" : "yellow", + }; + }); + + const items: MenuItem[] = [ + { + label: deps.UI_COPY.settings.previewHeading, + value: { type: "cancel" }, + kind: "heading", + }, + { + label: preview.label, + hint: preview.hint, + value: { type: "cancel" }, + color: "green", + disabled: true, + hideUnavailableSuffix: true, + }, + { label: "", value: { type: "cancel" }, separator: true }, + { + label: deps.UI_COPY.settings.displayHeading, + value: { type: "cancel" }, + kind: "heading", + }, + ...optionItems, + { label: "", value: { type: "cancel" }, separator: true }, + { + label: deps.UI_COPY.settings.moveUp, + value: { type: "move-up", key: focusKey }, + color: "green", + }, + { + label: deps.UI_COPY.settings.moveDown, + value: { type: "move-down", key: focusKey }, + color: "green", + }, + { label: "", value: { type: "cancel" }, separator: true }, + { + label: deps.UI_COPY.settings.resetDefault, + value: { type: "reset" }, + color: "yellow", + }, + { + label: deps.UI_COPY.settings.saveAndBack, + value: { type: "save" }, + color: "green", + }, + { + label: deps.UI_COPY.settings.backNoSave, + value: { type: "cancel" }, + color: "red", + }, + ]; + + const initialCursor = items.findIndex( + (item) => item.value.type === "toggle" && item.value.key === focusKey, + ); + + const updateFocusedPreview = (cursor: number) => { + const focusedItem = items[cursor]; + const focused = + focusedItem?.value.type === "toggle" ? focusedItem.value.key : focusKey; + const nextPreview = deps.buildAccountListPreview(draft, ui, focused); + const previewItem = items[1]; + if (!previewItem) return; + previewItem.label = nextPreview.label; + previewItem.hint = nextPreview.hint; + }; + + const result = await select(items, { + message: deps.UI_COPY.settings.summaryTitle, + subtitle: deps.UI_COPY.settings.summarySubtitle, + help: deps.UI_COPY.settings.summaryHelp, + clearScreen: true, + theme: ui.theme, + selectedEmphasis: "minimal", + initialCursor: initialCursor >= 0 ? initialCursor : undefined, + onCursorChange: ({ cursor }) => { + const focusedItem = items[cursor]; + if (focusedItem?.value.type === "toggle") + focusKey = focusedItem.value.key; + updateFocusedPreview(cursor); + }, + onInput: (raw) => { + const lower = raw.toLowerCase(); + if (lower === "q") return { type: "cancel" }; + if (lower === "s") return { type: "save" }; + if (lower === "r") return { type: "reset" }; + if (lower === "[") return { type: "move-up", key: focusKey }; + if (lower === "]") return { type: "move-down", key: focusKey }; + const parsed = Number.parseInt(raw, 10); + if ( + Number.isFinite(parsed) && + parsed >= 1 && + parsed <= deps.STATUSLINE_FIELD_OPTIONS.length + ) { + const target = deps.STATUSLINE_FIELD_OPTIONS[parsed - 1]; + if (target) return { type: "toggle", key: target.key }; + } + return undefined; + }, + }); + + if (!result || result.type === "cancel") return null; + if (result.type === "save") return draft; + if (result.type === "reset") { + draft = deps.applyDashboardDefaultsForKeys( + draft, + deps.STATUSLINE_PANEL_KEYS, + ); + focusKey = draft.menuStatuslineFields?.[0] ?? "last-used"; + continue; + } + if (result.type === "move-up") { + draft = { + ...draft, + menuStatuslineFields: deps.reorderField( + deps.normalizeStatuslineFields(draft.menuStatuslineFields), + result.key, + -1, + ), + }; + focusKey = result.key; + continue; + } + if (result.type === "move-down") { + draft = { + ...draft, + menuStatuslineFields: deps.reorderField( + deps.normalizeStatuslineFields(draft.menuStatuslineFields), + result.key, + 1, + ), + }; + focusKey = result.key; + continue; + } + + focusKey = result.key; + const fields = deps.normalizeStatuslineFields(draft.menuStatuslineFields); + const isEnabled = fields.includes(result.key); + if (isEnabled) { + const next = fields.filter((field) => field !== result.key); + draft = { + ...draft, + menuStatuslineFields: next.length > 0 ? next : [result.key], + }; + } else { + draft = { ...draft, menuStatuslineFields: [...fields, result.key] }; + } + } +} From aa77ab75eff13d1d09248d2c88cd9af7602df753 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 11:54:17 +0800 Subject: [PATCH 051/376] refactor: extract storage error hints --- lib/storage.ts | 97 ++++++++++++++++++-------------------- lib/storage/error-hints.ts | 41 ++++++++++++++++ test/storage.test.ts | 1 + 3 files changed, 87 insertions(+), 52 deletions(-) create mode 100644 lib/storage/error-hints.ts diff --git a/lib/storage.ts b/lib/storage.ts index 0509925d..aabbe776 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -11,6 +11,11 @@ import { } from "./named-backup-export.js"; import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; import { AnyAccountStorageSchema, getValidationErrors } from "./schemas.js"; + +import { formatStorageErrorHint } from "./storage/error-hints.js"; + +export { formatStorageErrorHint } from "./storage/error-hints.js"; + import { type AccountMetadataV1, type AccountMetadataV3, @@ -122,7 +127,9 @@ export interface NamedBackupSummary { mtimeMs: number; } -async function collectNamedBackups(storagePath: string): Promise { +async function collectNamedBackups( + storagePath: string, +): Promise { const backupRoot = getNamedBackupRoot(storagePath); let entries: Array<{ isFile(): boolean; name: string }>; try { @@ -147,12 +154,15 @@ async function collectNamedBackups(storagePath: string): Promise null); if (statsAfter && statsAfter.mtimeMs !== statsBefore.mtimeMs) { - log.debug("backup file changed between stat and load, mtime may be stale", { - candidatePath, - fileName: entry.name, - beforeMtimeMs: statsBefore.mtimeMs, - afterMtimeMs: statsAfter.mtimeMs, - }); + log.debug( + "backup file changed between stat and load, mtime may be stale", + { + candidatePath, + fileName: entry.name, + beforeMtimeMs: statsBefore.mtimeMs, + afterMtimeMs: statsAfter.mtimeMs, + }, + ); } candidates.push({ path: candidatePath, @@ -161,17 +171,20 @@ async function collectNamedBackups(storagePath: string): Promise = Promise.resolve(); const transactionSnapshotContext = new AsyncLocalStorage<{ snapshot: AccountStorageV3 | null; @@ -949,7 +935,9 @@ export async function restoreAccountsFromBackup( !relativePath.startsWith("..") && !isAbsolute(relativePath); if (!isInsideBackupRoot) { - throw new Error(`Backup path must stay inside ${resolvedBackupRoot}: ${path}`); + throw new Error( + `Backup path must stay inside ${resolvedBackupRoot}: ${path}`, + ); } const { normalized } = await (async () => { @@ -958,15 +946,15 @@ export async function restoreAccountsFromBackup( } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code === "ENOENT") { - throw new Error( - `Backup file no longer exists: ${path}`, - ); + throw new Error(`Backup file no longer exists: ${path}`); } throw error; } })(); if (!normalized || normalized.accounts.length === 0) { - throw new Error(`Backup does not contain any accounts: ${resolvedBackupPath}`); + throw new Error( + `Backup does not contain any accounts: ${resolvedBackupPath}`, + ); } if (options?.persist !== false) { await saveAccounts(normalized); @@ -1330,7 +1318,10 @@ function findCompatibleRefreshTokenMatchIndex( matchingAccount = account; continue; } - const newest: T = selectNewestAccount(matchingAccount ?? undefined, account); + const newest: T = selectNewestAccount( + matchingAccount ?? undefined, + account, + ); if (newest === account) { matchingIndex = i; matchingAccount = account; @@ -2114,7 +2105,9 @@ export async function withAccountAndFlaggedStorageTransaction( accountStorage: AccountStorageV3, flaggedStorage: FlaggedAccountStorageV1, ): Promise => { - const previousAccounts = cloneAccountStorageForPersistence(state.snapshot); + const previousAccounts = cloneAccountStorageForPersistence( + state.snapshot, + ); const nextAccounts = cloneAccountStorageForPersistence(accountStorage); await saveAccountsUnlocked(nextAccounts); try { diff --git a/lib/storage/error-hints.ts b/lib/storage/error-hints.ts new file mode 100644 index 00000000..dd0b78ce --- /dev/null +++ b/lib/storage/error-hints.ts @@ -0,0 +1,41 @@ +import { StorageError } from "../storage.js"; + +export function formatStorageErrorHint(error: unknown, path: string): string { + const err = error as NodeJS.ErrnoException; + const code = err?.code || "UNKNOWN"; + const isWindows = process.platform === "win32"; + + switch (code) { + case "EACCES": + case "EPERM": + return isWindows + ? `Permission denied writing to ${path}. Check antivirus exclusions for this folder. Ensure you have write permissions.` + : `Permission denied writing to ${path}. Check folder permissions. Try: chmod 755 ~/.codex`; + case "EBUSY": + return `File is locked at ${path}. The file may be open in another program. Close any editors or processes accessing it.`; + case "ENOSPC": + return `Disk is full. Free up space and try again. Path: ${path}`; + case "EEMPTY": + return `File written but is empty. This may indicate a disk or filesystem issue. Path: ${path}`; + default: + return isWindows + ? `Failed to write to ${path}. Check folder permissions and ensure path contains no special characters.` + : `Failed to write to ${path}. Check folder permissions and disk space.`; + } +} + +export function toStorageError( + message: string, + error: unknown, + path: string, +): StorageError { + const err = error as NodeJS.ErrnoException; + const code = err?.code || "UNKNOWN"; + return new StorageError( + message, + code, + path, + formatStorageErrorHint(error, path), + error instanceof Error ? error : undefined, + ); +} diff --git a/test/storage.test.ts b/test/storage.test.ts index e69feac2..32480c74 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -27,6 +27,7 @@ import { withAccountAndFlaggedStorageTransaction, withAccountStorageTransaction, } from "../lib/storage.js"; +import { formatStorageErrorHint } from "../lib/storage/error-hints.js"; // Mocking the behavior we're about to implement for TDD // Since the functions aren't in lib/storage.ts yet, we'll need to mock them or From c8bcf1382791054e0cd2c791814192d9dd7db251 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 11:59:27 +0800 Subject: [PATCH 052/376] refactor: extract storage identity helpers --- lib/storage.ts | 74 ++++++----------------------------------- lib/storage/identity.ts | 62 ++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 63 deletions(-) create mode 100644 lib/storage/identity.ts diff --git a/lib/storage.ts b/lib/storage.ts index aabbe776..12c77dac 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -15,7 +15,15 @@ import { AnyAccountStorageSchema, getValidationErrors } from "./schemas.js"; import { formatStorageErrorHint } from "./storage/error-hints.js"; export { formatStorageErrorHint } from "./storage/error-hints.js"; +export { + getAccountIdentityKey, + normalizeEmailKey, +} from "./storage/identity.js"; +import { + type AccountIdentityRef, + toAccountIdentityRef, +} from "./storage/identity.js"; import { type AccountMetadataV1, type AccountMetadataV3, @@ -1143,57 +1151,6 @@ function selectNewestAccount( return candidateAddedAt >= currentAddedAt ? candidate : current; } -function normalizeAccountIdKey( - accountId: string | undefined, -): string | undefined { - if (!accountId) return undefined; - const trimmed = accountId.trim(); - return trimmed || undefined; -} - -/** - * Normalize email keys for case-insensitive account identity matching. - */ -export function normalizeEmailKey( - email: string | undefined, -): string | undefined { - if (!email) return undefined; - const trimmed = email.trim(); - if (!trimmed) return undefined; - return trimmed.toLowerCase(); -} - -function normalizeRefreshTokenKey( - refreshToken: string | undefined, -): string | undefined { - if (!refreshToken) return undefined; - const trimmed = refreshToken.trim(); - return trimmed || undefined; -} - -type AccountIdentityRef = { - accountId?: string; - emailKey?: string; - refreshToken?: string; -}; - -type AccountMatchOptions = { - allowUniqueAccountIdFallbackWithoutEmail?: boolean; -}; - -function toAccountIdentityRef( - account: - | Pick - | null - | undefined, -): AccountIdentityRef { - return { - accountId: normalizeAccountIdKey(account?.accountId), - emailKey: normalizeEmailKey(account?.email), - refreshToken: normalizeRefreshTokenKey(account?.refreshToken), - }; -} - function collectDistinctIdentityValues( values: Array, ): Set { @@ -1204,18 +1161,9 @@ function collectDistinctIdentityValues( return distinct; } -export function getAccountIdentityKey( - account: Pick, -): string | undefined { - const ref = toAccountIdentityRef(account); - if (ref.accountId && ref.emailKey) { - return `account:${ref.accountId}::email:${ref.emailKey}`; - } - if (ref.accountId) return `account:${ref.accountId}`; - if (ref.emailKey) return `email:${ref.emailKey}`; - if (ref.refreshToken) return `refresh:${ref.refreshToken}`; - return undefined; -} +type AccountMatchOptions = { + allowUniqueAccountIdFallbackWithoutEmail?: boolean; +}; function findNewestMatchingIndex( accounts: readonly T[], diff --git a/lib/storage/identity.ts b/lib/storage/identity.ts new file mode 100644 index 00000000..6fc21e7c --- /dev/null +++ b/lib/storage/identity.ts @@ -0,0 +1,62 @@ +type AccountLike = { + accountId?: string; + email?: string; + refreshToken?: string; +}; + +export type AccountIdentityRef = { + accountId?: string; + emailKey?: string; + refreshToken?: string; +}; + +export function normalizeAccountIdKey( + accountId: string | undefined, +): string | undefined { + if (!accountId) return undefined; + const trimmed = accountId.trim(); + return trimmed || undefined; +} + +export function normalizeEmailKey( + email: string | undefined, +): string | undefined { + if (!email) return undefined; + const trimmed = email.trim(); + if (!trimmed) return undefined; + return trimmed.toLowerCase(); +} + +export function normalizeRefreshTokenKey( + refreshToken: string | undefined, +): string | undefined { + if (!refreshToken) return undefined; + const trimmed = refreshToken.trim(); + return trimmed || undefined; +} + +export function toAccountIdentityRef( + account: + | Pick + | null + | undefined, +): AccountIdentityRef { + return { + accountId: normalizeAccountIdKey(account?.accountId), + emailKey: normalizeEmailKey(account?.email), + refreshToken: normalizeRefreshTokenKey(account?.refreshToken), + }; +} + +export function getAccountIdentityKey( + account: Pick, +): string | undefined { + const ref = toAccountIdentityRef(account); + if (ref.accountId && ref.emailKey) { + return `account:${ref.accountId}::email:${ref.emailKey}`; + } + if (ref.accountId) return `account:${ref.accountId}`; + if (ref.emailKey) return `email:${ref.emailKey}`; + if (ref.refreshToken) return `refresh:${ref.refreshToken}`; + return undefined; +} From 9f83b152d7050c2ed265365ff8f3411ac4afe2f6 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 11:59:59 +0800 Subject: [PATCH 053/376] fix: fail verify-flagged on missing transaction result --- lib/codex-manager/commands/verify-flagged.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/codex-manager/commands/verify-flagged.ts b/lib/codex-manager/commands/verify-flagged.ts index 59e85348..734aba52 100644 --- a/lib/codex-manager/commands/verify-flagged.ts +++ b/lib/codex-manager/commands/verify-flagged.ts @@ -337,7 +337,13 @@ export async function runVerifyFlaggedCommand( transactionResult = attemptResult; }, ); - if (transactionResult) assignRefreshCheckResult(transactionResult); + if (!transactionResult) { + logError( + "verify-flagged: transaction completed without a result; storage may be unchanged", + ); + return 1; + } + assignRefreshCheckResult(transactionResult); } } else { assignRefreshCheckResult( From 7502a9e4b16365a6caa6b90f4c2d6349115a812d Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 11:59:59 +0800 Subject: [PATCH 054/376] fix: clean up report temp files on write failure --- lib/codex-manager/commands/report.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/codex-manager/commands/report.ts b/lib/codex-manager/commands/report.ts index 224a68cd..55d006fe 100644 --- a/lib/codex-manager/commands/report.ts +++ b/lib/codex-manager/commands/report.ts @@ -174,9 +174,9 @@ function serializeForecastResults( async function defaultWriteFile(path: string, contents: string): Promise { await fs.mkdir(dirname(path), { recursive: true }); const tempPath = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; - await fs.writeFile(tempPath, contents, "utf-8"); let moved = false; try { + await fs.writeFile(tempPath, contents, "utf-8"); for (let attempt = 0; attempt < 5; attempt += 1) { try { await fs.rename(tempPath, path); From 4222e88e3219f19423bf49dcaed51e407265c77c Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 12:00:20 +0800 Subject: [PATCH 055/376] fix: fall back cleanly from live fix probes --- lib/codex-manager/commands/fix.ts | 69 +++++++++------ test/codex-manager-fix-command.test.ts | 117 ++++++++++++++++++++++++- 2 files changed, 158 insertions(+), 28 deletions(-) diff --git a/lib/codex-manager/commands/fix.ts b/lib/codex-manager/commands/fix.ts index 9201e55e..8cabdd17 100644 --- a/lib/codex-manager/commands/fix.ts +++ b/lib/codex-manager/commands/fix.ts @@ -174,7 +174,30 @@ export async function runFixCommand( deps.setStoragePath(null); const storage = await deps.loadAccounts(); if (!storage || storage.accounts.length === 0) { - logInfo("No accounts configured."); + if (options.json) { + logInfo( + JSON.stringify( + { + command: "fix", + dryRun: options.dryRun, + liveProbe: options.live, + model: options.model, + changed: false, + summary: { healthy: 0, disabled: 0, warnings: 0, skipped: 0 }, + recommendation: { + recommendedIndex: null, + reason: "No accounts configured.", + }, + recommendedSwitchCommand: null, + reports: [] as FixAccountReport[], + }, + null, + 2, + ), + ); + } else { + logInfo("No accounts configured."); + } return 0; } let quotaEmailFallbackState = @@ -205,6 +228,7 @@ export async function runFixCommand( } if (deps.hasUsableAccessToken(account, now)) { + let needsRefresh = false; if (options.live) { const currentAccessToken = account.accessToken; const probeAccountId = currentAccessToken @@ -235,33 +259,26 @@ export async function runFixCommand( : "live session OK", }); continue; - } catch (error) { - const message = deps.normalizeFailureDetail( - error instanceof Error ? error.message : String(error), - undefined, - ); - reports.push({ - index: i, - label, - outcome: "warning-soft-failure", - message: `live probe failed (${message}), trying refresh fallback`, - }); + } catch { + needsRefresh = true; } } } - const refreshWarning = deps.hasLikelyInvalidRefreshToken( - account.refreshToken, - ) - ? " (refresh token looks stale; re-login recommended)" - : ""; - reports.push({ - index: i, - label, - outcome: "healthy", - message: `access token still valid${refreshWarning}`, - }); - continue; + if (!needsRefresh) { + const refreshWarning = deps.hasLikelyInvalidRefreshToken( + account.refreshToken, + ) + ? " (refresh token looks stale; re-login recommended)" + : ""; + reports.push({ + index: i, + label, + outcome: "healthy", + message: `access token still valid${refreshWarning}`, + }); + continue; + } } const refreshResult = await deps.queuedRefresh(account.refreshToken); @@ -426,7 +443,7 @@ export async function runFixCommand( if (changed && !options.dryRun) await deps.saveAccounts(storage); if (options.json) { - if (workingQuotaCache && quotaCacheChanged) + if (workingQuotaCache && quotaCacheChanged && !options.dryRun) await deps.saveQuotaCache(workingQuotaCache); logInfo( JSON.stringify( @@ -525,7 +542,7 @@ export async function runFixCommand( ); } } - if (workingQuotaCache && quotaCacheChanged) + if (workingQuotaCache && quotaCacheChanged && !options.dryRun) await deps.saveQuotaCache(workingQuotaCache); if (changed && options.dryRun) logInfo( diff --git a/test/codex-manager-fix-command.test.ts b/test/codex-manager-fix-command.test.ts index 6ac130a2..33beedc2 100644 --- a/test/codex-manager-fix-command.test.ts +++ b/test/codex-manager-fix-command.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { + type FixAccountReport, runFixCommand, type FixCliOptions, type FixCommandDeps, @@ -84,9 +85,121 @@ describe("runFixCommand", () => { }); it("prints json output for empty storage", async () => { - const deps = createDeps(); + const deps = createDeps({ + loadAccounts: vi.fn(async () => null), + }); const result = await runFixCommand([], deps); expect(result).toBe(0); - expect(deps.logInfo).toHaveBeenCalledWith(expect.stringContaining("No accounts configured.")); + const payload = JSON.parse(String((deps.logInfo as ReturnType).mock.calls[0]?.[0])) as { + command: string; + reports: FixAccountReport[]; + recommendation: { recommendedIndex: number | null; reason: string }; + }; + expect(payload.command).toBe("fix"); + expect(payload.reports).toEqual([]); + expect(payload.recommendation).toEqual({ + recommendedIndex: null, + reason: "No accounts configured.", + }); + }); + + it("falls back to refresh when live probe fails for a usable access token", async () => { + const storage = createStorage(); + storage.accounts.push({ + email: "fix@example.com", + refreshToken: "refresh-token", + accessToken: "access-token", + accountId: "acc_1", + expiresAt: 9_999, + addedAt: 0, + lastUsed: 0, + enabled: true, + }); + const deps = createDeps({ + loadAccounts: vi.fn(async () => structuredClone(storage)), + parseFixArgs: vi.fn(() => ({ + ok: true as const, + options: { + dryRun: false, + json: true, + live: true, + model: "gpt-5-codex", + } satisfies FixCliOptions, + })), + fetchCodexQuotaSnapshot: vi + .fn() + .mockRejectedValueOnce(new Error("probe exploded")) + .mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + }), + extractAccountId: vi.fn((accessToken?: string) => + accessToken ? "acc_1" : undefined, + ), + queuedRefresh: vi.fn(async () => ({ + type: "success", + access: "access-refreshed", + refresh: "refresh-refreshed", + expires: 8_000, + idToken: "id-token", + })), + }); + + const result = await runFixCommand([], deps); + + expect(result).toBe(0); + expect(deps.queuedRefresh).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String((deps.logInfo as ReturnType).mock.calls[0]?.[0])) as { + reports: Array<{ outcome: string; message: string }>; + }; + expect(payload.reports).toHaveLength(1); + expect(payload.reports[0]).toMatchObject({ + outcome: "healthy", + }); + expect(payload.reports[0]?.message).toContain( + "refresh + live probe succeeded", + ); + }); + + it("does not persist quota cache during dry-run", async () => { + const storage = createStorage(); + storage.accounts.push({ + email: "fix@example.com", + refreshToken: "refresh-token", + accessToken: "access-token", + accountId: "acc_1", + expiresAt: 9_999, + addedAt: 0, + lastUsed: 0, + enabled: true, + }); + const deps = createDeps({ + loadAccounts: vi.fn(async () => structuredClone(storage)), + parseFixArgs: vi.fn(() => ({ + ok: true as const, + options: { + dryRun: true, + json: true, + live: true, + model: "gpt-5-codex", + } satisfies FixCliOptions, + })), + loadQuotaCache: vi.fn(async () => ({ + version: 1, + byAccountId: {}, + byEmail: {}, + })), + updateQuotaCacheForAccount: vi.fn(() => true), + extractAccountId: vi.fn((accessToken?: string) => + accessToken ? "acc_1" : undefined, + ), + }); + + const result = await runFixCommand([], deps); + + expect(result).toBe(0); + expect(deps.saveQuotaCache).not.toHaveBeenCalled(); }); }); From 1399ce59e158e683b4f7bf77a3082ccf61a1d303 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 12:00:20 +0800 Subject: [PATCH 056/376] fix: sync doctor active account fixes --- lib/codex-manager/commands/doctor.ts | 184 +++++++++++++++++----- test/codex-manager-doctor-command.test.ts | 144 +++++++++++++++++ 2 files changed, 285 insertions(+), 43 deletions(-) diff --git a/lib/codex-manager/commands/doctor.ts b/lib/codex-manager/commands/doctor.ts index 13ddab44..8ff0ce88 100644 --- a/lib/codex-manager/commands/doctor.ts +++ b/lib/codex-manager/commands/doctor.ts @@ -163,39 +163,49 @@ export async function runDoctorCommand( if (existsSync(codexAuthPath)) { try { const raw = await fs.readFile(codexAuthPath, "utf-8"); - const parsed = JSON.parse(raw) as Record; - const tokens = - parsed.tokens && typeof parsed.tokens === "object" - ? (parsed.tokens as Record) - : null; - const accessToken = - tokens && typeof tokens.access_token === "string" - ? tokens.access_token - : undefined; - const idToken = - tokens && typeof tokens.id_token === "string" - ? tokens.id_token - : undefined; - const accountIdFromFile = - tokens && typeof tokens.account_id === "string" - ? tokens.account_id - : undefined; - const emailFromFile = - typeof parsed.email === "string" ? parsed.email : undefined; - codexAuthEmail = deps.sanitizeEmail( - emailFromFile ?? deps.extractAccountEmail(accessToken, idToken), - ); - codexAuthAccountId = - accountIdFromFile ?? deps.extractAccountId(accessToken); - addCheck({ - key: "codex-auth-readable", - severity: "ok", - message: "Codex auth file is readable", - details: - codexAuthEmail || codexAuthAccountId - ? `email=${codexAuthEmail ?? "unknown"}, accountId=${codexAuthAccountId ?? "unknown"}` - : undefined, - }); + const parsedUnknown = JSON.parse(raw) as unknown; + if (!parsedUnknown || typeof parsedUnknown !== "object") { + addCheck({ + key: "codex-auth-readable", + severity: "error", + message: "Codex auth file contains invalid JSON shape", + details: codexAuthPath, + }); + } else { + const parsed = parsedUnknown as Record; + const tokens = + parsed.tokens && typeof parsed.tokens === "object" + ? (parsed.tokens as Record) + : null; + const accessToken = + tokens && typeof tokens.access_token === "string" + ? tokens.access_token + : undefined; + const idToken = + tokens && typeof tokens.id_token === "string" + ? tokens.id_token + : undefined; + const accountIdFromFile = + tokens && typeof tokens.account_id === "string" + ? tokens.account_id + : undefined; + const emailFromFile = + typeof parsed.email === "string" ? parsed.email : undefined; + codexAuthEmail = deps.sanitizeEmail( + emailFromFile ?? deps.extractAccountEmail(accessToken, idToken), + ); + codexAuthAccountId = + accountIdFromFile ?? deps.extractAccountId(accessToken); + addCheck({ + key: "codex-auth-readable", + severity: "ok", + message: "Codex auth file is readable", + details: + codexAuthEmail || codexAuthAccountId + ? `email=${codexAuthEmail ?? "unknown"}, accountId=${codexAuthAccountId ?? "unknown"}` + : undefined, + }); + } } catch (error) { addCheck({ key: "codex-auth-readable", @@ -256,20 +266,12 @@ export async function runDoctorCommand( const storage = await deps.loadAccounts(); let fixChanged = false; let fixActions: DoctorFixAction[] = []; + let storageNeedsSave = false; if (options.fix && storage && storage.accounts.length > 0) { const fixed = deps.applyDoctorFixes(storage); fixChanged = fixed.changed; fixActions = fixed.actions; - if (fixChanged && !options.dryRun) await deps.saveAccounts(storage); - addCheck({ - key: "auto-fix", - severity: fixChanged ? "warn" : "ok", - message: fixChanged - ? options.dryRun - ? `Prepared ${fixActions.length} fix(es) (dry-run)` - : `Applied ${fixActions.length} fix(es)` - : "No safe auto-fixes needed", - }); + storageNeedsSave = fixed.changed; } if (!storage || storage.accounts.length === 0) { @@ -414,9 +416,105 @@ export async function runDoctorCommand( : "Manager active account and Codex active account are aligned", details: `manager=${managerActiveEmail ?? managerActiveAccountId ?? "unknown"} | codex=${codexActiveEmail ?? codexActiveAccountId ?? "unknown"}`, }); + + if (options.fix && activeAccount) { + let syncAccessToken = activeAccount.accessToken; + let syncRefreshToken = activeAccount.refreshToken; + let syncExpiresAt = activeAccount.expiresAt; + let syncIdToken: string | undefined; + + if (!deps.hasUsableAccessToken(activeAccount, now)) { + if (options.dryRun) { + fixChanged = true; + fixActions.push({ + key: "doctor-refresh", + message: `Prepared active-account token refresh for account ${activeIndex + 1} (dry-run)`, + }); + } else { + const refreshResult = await deps.queuedRefresh(activeAccount.refreshToken); + if (refreshResult.type === "success") { + const refreshedEmail = deps.sanitizeEmail( + deps.extractAccountEmail(refreshResult.access, refreshResult.idToken), + ); + const refreshedAccountId = deps.extractAccountId(refreshResult.access); + activeAccount.accessToken = refreshResult.access; + activeAccount.refreshToken = refreshResult.refresh; + activeAccount.expiresAt = refreshResult.expires; + if (refreshedEmail) activeAccount.email = refreshedEmail; + deps.applyTokenAccountIdentity(activeAccount, refreshedAccountId); + syncAccessToken = refreshResult.access; + syncRefreshToken = refreshResult.refresh; + syncExpiresAt = refreshResult.expires; + syncIdToken = refreshResult.idToken; + storageNeedsSave = true; + fixChanged = true; + fixActions.push({ + key: "doctor-refresh", + message: `Refreshed active account tokens for account ${activeIndex + 1}`, + }); + } else { + addCheck({ + key: "doctor-refresh", + severity: "warn", + message: "Unable to refresh active account before Codex sync", + details: deps.normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ), + }); + } + } + } + + if (!options.dryRun) { + const synced = await deps.setCodexCliActiveSelection({ + accountId: activeAccount.accountId, + email: activeAccount.email, + accessToken: syncAccessToken, + refreshToken: syncRefreshToken, + expiresAt: syncExpiresAt, + ...(syncIdToken ? { idToken: syncIdToken } : {}), + }); + if (synced) { + fixChanged = true; + fixActions.push({ + key: "codex-active-sync", + message: "Synced manager active account into Codex auth state", + }); + } else { + addCheck({ + key: "codex-active-sync", + severity: "warn", + message: "Failed to sync manager active account into Codex auth state", + }); + } + } else { + fixChanged = true; + fixActions.push({ + key: "codex-active-sync", + message: "Prepared Codex active-account sync (dry-run)", + }); + } + } } } + if (options.fix) { + addCheck({ + key: "auto-fix", + severity: fixChanged ? "warn" : "ok", + message: fixChanged + ? options.dryRun + ? `Prepared ${fixActions.length} fix(es) (dry-run)` + : `Applied ${fixActions.length} fix(es)` + : "No safe auto-fixes needed", + }); + } + + if (storageNeedsSave && !options.dryRun && storage) { + await deps.saveAccounts(storage); + } + const summary = checks.reduce( (acc, check) => { acc[check.severity] += 1; diff --git a/test/codex-manager-doctor-command.test.ts b/test/codex-manager-doctor-command.test.ts index a04cae35..fe6910d7 100644 --- a/test/codex-manager-doctor-command.test.ts +++ b/test/codex-manager-doctor-command.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { type DoctorCliOptions, @@ -5,6 +8,7 @@ import { runDoctorCommand, } from "../lib/codex-manager/commands/doctor.js"; import type { AccountStorageV3 } from "../lib/storage.js"; +import type { TokenResult } from "../lib/types.js"; function createStorage(): AccountStorageV3 { return { @@ -68,6 +72,21 @@ function createDeps( }; } +function createDoctorFiles(files: Record): { + pathFor: (name: string) => string; + cleanup: () => void; +} { + const root = mkdtempSync(join(tmpdir(), "doctor-command-test-")); + const pathFor = (name: string) => join(root, name); + for (const [name, contents] of Object.entries(files)) { + writeFileSync(pathFor(name), contents, "utf8"); + } + return { + pathFor, + cleanup: () => rmSync(root, { recursive: true, force: true }), + }; +} + describe("runDoctorCommand", () => { it("prints usage for help", async () => { const deps = createDeps(); @@ -91,4 +110,129 @@ describe("runDoctorCommand", () => { expect.stringContaining('"command": "doctor"'), ); }); + + it("reports an invalid Codex auth JSON shape", async () => { + const files = createDoctorFiles({ + "storage.json": JSON.stringify(createStorage()), + "auth.json": "123", + "config.toml": 'cli_auth_credentials_store = "file"\n', + }); + try { + const deps = createDeps({ + getStoragePath: vi.fn(() => files.pathFor("storage.json")), + getCodexCliAuthPath: vi.fn(() => files.pathFor("auth.json")), + getCodexCliConfigPath: vi.fn(() => files.pathFor("config.toml")), + }); + + const result = await runDoctorCommand([], deps); + expect(result).toBe(1); + + const payload = JSON.parse( + String(vi.mocked(deps.logInfo!).mock.calls.at(-1)?.[0] ?? ""), + ) as { + checks: Array<{ key: string; severity: string; message: string }>; + }; + expect(payload.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: "codex-auth-readable", + severity: "error", + message: "Codex auth file contains invalid JSON shape", + }), + ]), + ); + } finally { + files.cleanup(); + } + }); + + it("refreshes and syncs the active account once when fixing", async () => { + const files = createDoctorFiles({ + "storage.json": JSON.stringify(createStorage()), + "auth.json": JSON.stringify({ tokens: { access_token: "old-access" } }), + "config.toml": 'cli_auth_credentials_store = "file"\n', + }); + try { + const storage = createStorage(); + storage.accounts.push({ + email: "stale@example.com", + accountId: "acct-stale", + accessToken: "old-access", + refreshToken: "refresh-old", + expiresAt: 100, + }); + const refreshResult: TokenResult = { + type: "success", + access: "new-access", + refresh: "refresh-new", + expires: 5_000, + idToken: "id-token-new", + }; + const applyTokenAccountIdentity = vi.fn( + (account: AccountStorageV3["accounts"][number], accountId: string | undefined) => { + if (!accountId || account.accountId === accountId) return false; + account.accountId = accountId; + return true; + }, + ); + const deps = createDeps({ + getStoragePath: vi.fn(() => files.pathFor("storage.json")), + getCodexCliAuthPath: vi.fn(() => files.pathFor("auth.json")), + getCodexCliConfigPath: vi.fn(() => files.pathFor("config.toml")), + parseDoctorArgs: vi.fn(() => ({ + ok: true as const, + options: { + json: true, + fix: true, + dryRun: false, + } satisfies DoctorCliOptions, + })), + loadAccounts: vi.fn(async () => storage), + hasUsableAccessToken: vi.fn(() => false), + queuedRefresh: vi.fn(async () => refreshResult), + extractAccountEmail: vi.fn((accessToken?: string) => + accessToken === "new-access" ? "fresh@example.com" : undefined, + ), + extractAccountId: vi.fn((accessToken?: string) => + accessToken === "new-access" ? "acct-fresh" : undefined, + ), + applyTokenAccountIdentity, + }); + + const result = await runDoctorCommand([], deps); + expect(result).toBe(0); + expect(deps.queuedRefresh).toHaveBeenCalledWith("refresh-old"); + expect(deps.saveAccounts).toHaveBeenCalledTimes(1); + expect(deps.setCodexCliActiveSelection).toHaveBeenCalledWith({ + accountId: "acct-fresh", + email: "fresh@example.com", + accessToken: "new-access", + refreshToken: "refresh-new", + expiresAt: 5_000, + idToken: "id-token-new", + }); + expect(storage.accounts[0]).toMatchObject({ + email: "fresh@example.com", + accountId: "acct-fresh", + accessToken: "new-access", + refreshToken: "refresh-new", + expiresAt: 5_000, + }); + + const payload = JSON.parse( + String(vi.mocked(deps.logInfo!).mock.calls.at(-1)?.[0] ?? ""), + ) as { + fix: { changed: boolean; actions: Array<{ key: string; message: string }> }; + }; + expect(payload.fix.changed).toBe(true); + expect(payload.fix.actions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: "doctor-refresh" }), + expect.objectContaining({ key: "codex-active-sync" }), + ]), + ); + } finally { + files.cleanup(); + } + }); }); From 61d4c13f53316229d06fa4be63701e04b69b5507 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 12:00:53 +0800 Subject: [PATCH 057/376] fix: preserve manage selection and flagged repair state --- lib/codex-manager.ts | 51 ++++++++++++++++++-- lib/codex-manager/repair-commands.ts | 60 ++++++++++++++++-------- test/codex-manager-cli.test.ts | 70 ++++++++++++++++++++++++++-- 3 files changed, 153 insertions(+), 28 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 3fc6ea8d..206bc7e1 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -2055,11 +2055,54 @@ async function clearAccountsAndReset(): Promise { await clearAccounts(); } -function resetManageActionSelection(storage: AccountStorageV3): void { - storage.activeIndex = 0; +function adjustManageActionSelectionIndex( + currentIndex: number | undefined, + removedIndex: number, + remainingCount: number, +): number { + if (remainingCount <= 0) { + return 0; + } + if (typeof currentIndex !== "number" || currentIndex < 0) { + return 0; + } + if (currentIndex < removedIndex) { + return Math.min(currentIndex, remainingCount - 1); + } + if (currentIndex > removedIndex) { + return currentIndex - 1; + } + return Math.min(removedIndex, remainingCount - 1); +} + +function resetManageActionSelection( + storage: AccountStorageV3, + removedIndex: number, +): void { + const remainingCount = storage.accounts.length; + if (remainingCount <= 0) { + storage.activeIndex = 0; + storage.activeIndexByFamily = {}; + for (const family of MODEL_FAMILIES) { + storage.activeIndexByFamily[family] = 0; + } + return; + } + + const previousActiveIndex = storage.activeIndex; + const previousByFamily = { ...storage.activeIndexByFamily }; + storage.activeIndex = adjustManageActionSelectionIndex( + previousActiveIndex, + removedIndex, + remainingCount, + ); storage.activeIndexByFamily = {}; for (const family of MODEL_FAMILIES) { - storage.activeIndexByFamily[family] = 0; + storage.activeIndexByFamily[family] = adjustManageActionSelectionIndex( + previousByFamily[family] ?? previousActiveIndex, + removedIndex, + remainingCount, + ); } } @@ -2150,7 +2193,7 @@ async function handleManageAction( return; } nextStorage.accounts.splice(nextIndex, 1); - resetManageActionSelection(nextStorage); + resetManageActionSelection(nextStorage, nextIndex); await persist(nextStorage); replaceManageActionStorage(storage, nextStorage); deleted = true; diff --git a/lib/codex-manager/repair-commands.ts b/lib/codex-manager/repair-commands.ts index 134d338b..8cdc67f6 100644 --- a/lib/codex-manager/repair-commands.ts +++ b/lib/codex-manager/repair-commands.ts @@ -810,25 +810,43 @@ export async function runVerifyFlagged( const nextFlaggedAccounts: FlaggedAccountMetadataV1[] = []; const flaggedMutations: FlaggedStorageMutation[] = []; const now = Date.now(); - const refreshChecks: Array<{ + const collectRefreshChecks = async ( + accounts: FlaggedAccountMetadataV1[], + ): Promise< + Array<{ + index: number; + flagged: FlaggedAccountMetadataV1; + label: string; + result: Awaited>; + }> + > => { + const refreshChecks: Array<{ + index: number; + flagged: FlaggedAccountMetadataV1; + label: string; + result: Awaited>; + }> = []; + for (let i = 0; i < accounts.length; i += 1) { + const flagged = accounts[i]; + if (!flagged) continue; + refreshChecks.push({ + index: i, + flagged, + label: formatAccountLabel(flagged, i), + result: await queuedRefresh(flagged.refreshToken), + }); + } + return refreshChecks; + }; + const applyRefreshChecks = ( + storage: AccountStorageV3, + refreshChecks: Array<{ index: number; flagged: FlaggedAccountMetadataV1; label: string; result: Awaited>; - }> = []; - - for (let i = 0; i < flaggedStorage.accounts.length; i += 1) { - const flagged = flaggedStorage.accounts[i]; - if (!flagged) continue; - refreshChecks.push({ - index: i, - flagged, - label: formatAccountLabel(flagged, i), - result: await queuedRefresh(flagged.refreshToken), - }); - } - - const applyRefreshChecks = (storage: AccountStorageV3): void => { + }>, + ): void => { for (const check of refreshChecks) { const { index: i, flagged, label, result } = check; if (result.type === "success") { @@ -950,10 +968,14 @@ export async function runVerifyFlagged( }; let remainingFlagged = 0; + const refreshChecks = await collectRefreshChecks(flaggedStorage.accounts); if (options.restore) { if (options.dryRun) { - applyRefreshChecks((await loadAccounts()) ?? createEmptyAccountStorage()); + applyRefreshChecks( + (await loadAccounts()) ?? createEmptyAccountStorage(), + refreshChecks, + ); } else { await withAccountAndFlaggedStorageTransaction( async (loadedStorage, persist, loadedFlaggedStorage) => { @@ -961,7 +983,7 @@ export async function runVerifyFlagged( ? structuredClone(loadedStorage) : createEmptyAccountStorage(); const nextFlaggedStorage = structuredClone(loadedFlaggedStorage); - applyRefreshChecks(nextStorage); + applyRefreshChecks(nextStorage, refreshChecks); applyFlaggedStorageMutations(nextFlaggedStorage, flaggedMutations); remainingFlagged = nextFlaggedStorage.accounts.length; if (!storageChanged && !flaggedChanged) { @@ -975,7 +997,7 @@ export async function runVerifyFlagged( ); } } else { - applyRefreshChecks(createEmptyAccountStorage()); + applyRefreshChecks(createEmptyAccountStorage(), refreshChecks); remainingFlagged = nextFlaggedAccounts.length; } @@ -992,7 +1014,7 @@ export async function runVerifyFlagged( ).length; const changed = storageChanged || flaggedChanged; - if (!options.dryRun && flaggedChanged && !options.restore) { + if (!options.dryRun && !options.restore && flaggedChanged) { await withFlaggedStorageTransaction(async (loadedFlaggedStorage, persist) => { const nextFlaggedStorage = structuredClone(loadedFlaggedStorage); applyFlaggedStorageMutations(nextFlaggedStorage, flaggedMutations); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index abe5a238..e9476c16 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -8360,19 +8360,26 @@ describe("codex manager cli commands", () => { const now = Date.now(); loadAccountsMock.mockResolvedValue({ version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, accounts: [ { email: "first@example.com", refreshToken: "refresh-first", - addedAt: now - 2_000, - lastUsed: now - 2_000, + addedAt: now - 3_000, + lastUsed: now - 3_000, enabled: true, }, { email: "second@example.com", refreshToken: "refresh-second", + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + { + email: "third@example.com", + refreshToken: "refresh-third", addedAt: now - 1_000, lastUsed: now - 1_000, enabled: true, @@ -8388,10 +8395,63 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(saveAccountsMock).toHaveBeenCalledTimes(1); - expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts).toHaveLength(1); + expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts).toHaveLength(2); expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts?.[0]?.email).toBe( "first@example.com", ); + expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts?.[1]?.email).toBe( + "third@example.com", + ); + expect(saveAccountsMock.mock.calls[0]?.[0]?.activeIndex).toBe(1); + expect(saveAccountsMock.mock.calls[0]?.[0]?.activeIndexByFamily?.codex).toBe( + 1, + ); + }); + + it("preserves the active selection when deleting a different account", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 2, + activeIndexByFamily: { codex: 2 }, + accounts: [ + { + email: "first@example.com", + refreshToken: "refresh-first", + addedAt: now - 3_000, + lastUsed: now - 3_000, + enabled: true, + }, + { + email: "second@example.com", + refreshToken: "refresh-second", + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + { + email: "third@example.com", + refreshToken: "refresh-third", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "manage", deleteAccountIndex: 1 }) + .mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts).toHaveLength(2); + expect(saveAccountsMock.mock.calls[0]?.[0]?.activeIndex).toBe(1); + expect(saveAccountsMock.mock.calls[0]?.[0]?.activeIndexByFamily?.codex).toBe( + 1, + ); }); it("toggles account enabled state from manage mode", async () => { From 661dfa1e5bc852d7faf48608ee8aac842d4c34b5 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 12:00:53 +0800 Subject: [PATCH 058/376] refactor: share fix command dependency wiring --- lib/codex-manager.ts | 102 ++++++++++++++++--------------------------- 1 file changed, 38 insertions(+), 64 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 7864539f..4c207ab8 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -45,6 +45,7 @@ import { } from "./codex-manager/commands/doctor.js"; import { type FixCliOptions, + type FixCommandDeps, runFixCommand, } from "./codex-manager/commands/fix.js"; import { runForecastCommand } from "./codex-manager/commands/forecast.js"; @@ -2803,6 +2804,41 @@ async function clearAccountsAndReset(): Promise { await clearAccounts(); } +function buildFixCommandDeps(): FixCommandDeps { + return { + setStoragePath, + loadAccounts, + parseFixArgs, + printFixUsage, + loadQuotaCache, + saveQuotaCache, + cloneQuotaCacheData, + buildQuotaEmailFallbackState, + updateQuotaCacheForAccount, + pruneUnsafeQuotaEmailCacheEntry, + resolveActiveIndex, + hasUsableAccessToken, + fetchCodexQuotaSnapshot, + formatCompactQuotaSnapshot, + normalizeFailureDetail, + hasLikelyInvalidRefreshToken, + queuedRefresh, + sanitizeEmail, + extractAccountEmail, + extractAccountId, + applyTokenAccountIdentity, + isHardRefreshFailure, + evaluateForecastAccounts, + recommendForecastAccount, + saveAccounts, + formatAccountLabel, + stylePromptText, + formatResultSummary, + styleAccountDetailText, + defaultDisplay: DEFAULT_DASHBOARD_DISPLAY_SETTINGS, + }; +} + async function handleManageAction( storage: AccountStorageV3, menuResult: Awaited>, @@ -2987,38 +3023,7 @@ async function runAuthLogin(args: string[]): Promise { "Auto-Fix", "Checking and fixing common issues", async () => { - await runFixCommand(["--live"], { - setStoragePath, - loadAccounts, - parseFixArgs, - printFixUsage, - loadQuotaCache, - saveQuotaCache, - cloneQuotaCacheData, - buildQuotaEmailFallbackState, - updateQuotaCacheForAccount, - pruneUnsafeQuotaEmailCacheEntry, - resolveActiveIndex, - hasUsableAccessToken, - fetchCodexQuotaSnapshot, - formatCompactQuotaSnapshot, - normalizeFailureDetail, - hasLikelyInvalidRefreshToken, - queuedRefresh, - sanitizeEmail, - extractAccountEmail, - extractAccountId, - applyTokenAccountIdentity, - isHardRefreshFailure, - evaluateForecastAccounts, - recommendForecastAccount, - saveAccounts, - formatAccountLabel, - stylePromptText, - formatResultSummary, - styleAccountDetailText, - defaultDisplay: DEFAULT_DASHBOARD_DISPLAY_SETTINGS, - }); + await runFixCommand(["--live"], buildFixCommandDeps()); }, displaySettings, ); @@ -3564,38 +3569,7 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { }); } if (command === "fix") { - return runFixCommand(rest, { - setStoragePath, - loadAccounts, - parseFixArgs, - printFixUsage, - loadQuotaCache, - saveQuotaCache, - cloneQuotaCacheData, - buildQuotaEmailFallbackState, - updateQuotaCacheForAccount, - pruneUnsafeQuotaEmailCacheEntry, - resolveActiveIndex, - hasUsableAccessToken, - fetchCodexQuotaSnapshot, - formatCompactQuotaSnapshot, - normalizeFailureDetail, - hasLikelyInvalidRefreshToken, - queuedRefresh, - sanitizeEmail, - extractAccountEmail, - extractAccountId, - applyTokenAccountIdentity, - isHardRefreshFailure, - evaluateForecastAccounts, - recommendForecastAccount, - saveAccounts, - formatAccountLabel, - stylePromptText, - formatResultSummary, - styleAccountDetailText, - defaultDisplay: DEFAULT_DASHBOARD_DISPLAY_SETTINGS, - }); + return runFixCommand(rest, buildFixCommandDeps()); } if (command === "doctor") { return runDoctorCommand(rest, { From 21aaa890777bd4a1d277fd479cf243915bc50799 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 12:04:21 +0800 Subject: [PATCH 059/376] refactor: extract storage file path helpers --- lib/storage.ts | 43 +++++++++++------------------------ lib/storage/file-paths.ts | 48 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 30 deletions(-) create mode 100644 lib/storage/file-paths.ts diff --git a/lib/storage.ts b/lib/storage.ts index 12c77dac..bd98cfd1 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -20,6 +20,14 @@ export { normalizeEmailKey, } from "./storage/identity.js"; +import { + getFlaggedAccountsPath as buildFlaggedAccountsPath, + getLegacyFlaggedAccountsPath as buildLegacyFlaggedAccountsPath, + getAccountsBackupPath, + getAccountsBackupRecoveryCandidates, + getAccountsWalPath, + getIntentionalResetMarkerPath, +} from "./storage/file-paths.js"; import { type AccountIdentityRef, toAccountIdentityRef, @@ -57,7 +65,6 @@ const FLAGGED_ACCOUNTS_FILE_NAME = "openai-codex-flagged-accounts.json"; const LEGACY_FLAGGED_ACCOUNTS_FILE_NAME = "openai-codex-blocked-accounts.json"; const ACCOUNTS_BACKUP_SUFFIX = ".bak"; const ACCOUNTS_WAL_SUFFIX = ".wal"; -const ACCOUNTS_BACKUP_HISTORY_DEPTH = 3; const BACKUP_COPY_MAX_ATTEMPTS = 5; const BACKUP_COPY_BASE_DELAY_MS = 10; const RESET_MARKER_SUFFIX = ".reset-intent"; @@ -356,25 +363,6 @@ export function setStorageBackupEnabled(enabled: boolean): void { storageBackupEnabled = enabled; } -function getAccountsBackupPath(path: string): string { - return `${path}${ACCOUNTS_BACKUP_SUFFIX}`; -} - -function getAccountsBackupPathAtIndex(path: string, index: number): string { - if (index <= 0) { - return getAccountsBackupPath(path); - } - return `${path}${ACCOUNTS_BACKUP_SUFFIX}.${index}`; -} - -function getAccountsBackupRecoveryCandidates(path: string): string[] { - const candidates: string[] = []; - for (let i = 0; i < ACCOUNTS_BACKUP_HISTORY_DEPTH; i += 1) { - candidates.push(getAccountsBackupPathAtIndex(path, i)); - } - return candidates; -} - async function getAccountsBackupRecoveryCandidatesWithDiscovery( path: string, ): Promise { @@ -414,10 +402,6 @@ async function getAccountsBackupRecoveryCandidatesWithDiscovery( return [...knownCandidates, ...discoveredOrdered]; } -function getAccountsWalPath(path: string): string { - return `${path}${ACCOUNTS_WAL_SUFFIX}`; -} - async function copyFileWithRetry( sourcePath: string, destinationPath: string, @@ -594,10 +578,6 @@ function computeSha256(value: string): string { return createHash("sha256").update(value).digest("hex"); } -function getIntentionalResetMarkerPath(path: string): string { - return `${path}${RESET_MARKER_SUFFIX}`; -} - function createEmptyStorageWithMetadata( restoreEligible: boolean, restoreReason: RestoreReason, @@ -985,11 +965,14 @@ export async function exportNamedBackup( } export function getFlaggedAccountsPath(): string { - return join(dirname(getStoragePath()), FLAGGED_ACCOUNTS_FILE_NAME); + return buildFlaggedAccountsPath(getStoragePath(), FLAGGED_ACCOUNTS_FILE_NAME); } function getLegacyFlaggedAccountsPath(): string { - return join(dirname(getStoragePath()), LEGACY_FLAGGED_ACCOUNTS_FILE_NAME); + return buildLegacyFlaggedAccountsPath( + getStoragePath(), + LEGACY_FLAGGED_ACCOUNTS_FILE_NAME, + ); } async function migrateLegacyProjectStorageIfNeeded( diff --git a/lib/storage/file-paths.ts b/lib/storage/file-paths.ts new file mode 100644 index 00000000..64047df1 --- /dev/null +++ b/lib/storage/file-paths.ts @@ -0,0 +1,48 @@ +import { dirname, join } from "node:path"; + +const ACCOUNTS_BACKUP_SUFFIX = ".bak"; +const ACCOUNTS_WAL_SUFFIX = ".wal"; +const ACCOUNTS_BACKUP_HISTORY_DEPTH = 3; +const RESET_MARKER_SUFFIX = ".reset-intent"; + +export function getAccountsBackupPath(path: string): string { + return `${path}${ACCOUNTS_BACKUP_SUFFIX}`; +} + +export function getAccountsBackupPathAtIndex( + path: string, + index: number, +): string { + if (index <= 0) return getAccountsBackupPath(path); + return `${path}${ACCOUNTS_BACKUP_SUFFIX}.${index}`; +} + +export function getAccountsBackupRecoveryCandidates(path: string): string[] { + const candidates: string[] = []; + for (let i = 0; i < ACCOUNTS_BACKUP_HISTORY_DEPTH; i += 1) { + candidates.push(getAccountsBackupPathAtIndex(path, i)); + } + return candidates; +} + +export function getAccountsWalPath(path: string): string { + return `${path}${ACCOUNTS_WAL_SUFFIX}`; +} + +export function getIntentionalResetMarkerPath(path: string): string { + return `${path}${RESET_MARKER_SUFFIX}`; +} + +export function getFlaggedAccountsPath( + storagePath: string, + fileName: string, +): string { + return join(dirname(storagePath), fileName); +} + +export function getLegacyFlaggedAccountsPath( + storagePath: string, + legacyFileName: string, +): string { + return join(dirname(storagePath), legacyFileName); +} From d25de4c5a761521b96a5c43ac21f5423b95114a5 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 12:08:48 +0800 Subject: [PATCH 060/376] fix direct repair command review regressions --- lib/codex-manager/repair-commands.ts | 7 +- test/repair-commands.test.ts | 101 ++++++++++++++++++++++++++- 2 files changed, 104 insertions(+), 4 deletions(-) diff --git a/lib/codex-manager/repair-commands.ts b/lib/codex-manager/repair-commands.ts index 8cdc67f6..62bd6a7c 100644 --- a/lib/codex-manager/repair-commands.ts +++ b/lib/codex-manager/repair-commands.ts @@ -1404,7 +1404,7 @@ export async function runFix( }); } - const changed = accountStorageChanged || quotaCacheChanged; + const changed = accountStorageChanged; if (options.json) { if (!options.dryRun && workingQuotaCache && quotaCacheChanged) { @@ -1418,6 +1418,7 @@ export async function runFix( liveProbe: options.live, model: options.model, changed, + quotaCacheChanged, summary: reportSummary, recommendation, recommendedSwitchCommand: @@ -1688,7 +1689,8 @@ export async function runDoctor( details: codexCliState?.path, }); - const storage = await loadAccounts(); + const loadedStorage = await loadAccounts(); + const storage = loadedStorage ? structuredClone(loadedStorage) : loadedStorage; let fixChanged = false; let storageFixChanged = false; let structuralFixActions: DoctorFixAction[] = []; @@ -1992,6 +1994,7 @@ export async function runDoctor( transactionChanged = true; } if (!transactionChanged) { + structuralFixActions = []; storageFixChanged = false; return; } diff --git a/test/repair-commands.test.ts b/test/repair-commands.test.ts index fef81288..907facb2 100644 --- a/test/repair-commands.test.ts +++ b/test/repair-commands.test.ts @@ -434,7 +434,8 @@ describe("repair-commands direct deps coverage", () => { JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")), ).toMatchObject({ command: "fix", - changed: true, + changed: false, + quotaCacheChanged: true, summary: { healthy: 1, }, @@ -605,7 +606,13 @@ describe("repair-commands direct deps coverage", () => { }, ], activeIndex: 0, - activeIndexByFamily: { codex: 0 }, + activeIndexByFamily: { + codex: 0, + "codex-max": 0, + "gpt-5-codex": 0, + "gpt-5.1": 0, + "gpt-5.2": 0, + }, }, async (nextStorage: unknown) => { persistedAccountStorage = nextStorage; @@ -668,4 +675,94 @@ describe("repair-commands direct deps coverage", () => { }), ); }); + + it("runDoctor keeps the prescan snapshot unchanged when the transaction is already fixed", async () => { + const now = Date.now(); + let persistedAccountStorage: unknown; + const prescanStorage = { + version: 3, + accounts: [ + { + email: "doctor@example.com", + refreshToken: "doctor-refresh", + accessToken: "doctor-access", + expiresAt: now + 60_000, + accountId: "doctor-account", + accountIdSource: "manual" as const, + enabled: true, + }, + { + email: "doctor+duplicate@example.com", + refreshToken: "doctor-refresh", + accessToken: "doctor-access-duplicate", + expiresAt: now + 60_000, + accountId: "doctor-duplicate", + accountIdSource: "manual" as const, + enabled: true, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + loadAccountsMock.mockResolvedValueOnce(prescanStorage); + withAccountStorageTransactionMock.mockImplementation(async (handler) => + handler( + { + version: 3, + accounts: [ + { + email: "doctor@example.com", + refreshToken: "doctor-refresh", + accessToken: "doctor-access", + expiresAt: now + 60_000, + accountId: "doctor-account", + accountIdSource: "manual" as const, + enabled: true, + }, + { + email: "doctor+duplicate@example.com", + refreshToken: "doctor-refresh-2", + accessToken: "doctor-access-duplicate", + expiresAt: now + 60_000, + accountId: "doctor-duplicate", + accountIdSource: "manual" as const, + enabled: true, + }, + ], + activeIndex: 0, + activeIndexByFamily: { + codex: 0, + "codex-max": 0, + "gpt-5-codex": 0, + "gpt-5.1": 0, + "gpt-5.2": 0, + }, + }, + async (nextStorage: unknown) => { + persistedAccountStorage = nextStorage; + }, + ), + ); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const exitCode = await runDoctor( + ["--json", "--fix"], + createDeps({ + hasUsableAccessToken: () => true, + }), + ); + + expect(exitCode).toBe(0); + expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(persistedAccountStorage).toBeUndefined(); + expect(prescanStorage.accounts[1]?.enabled).toBe(true); + const payload = JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")) as { + fix: { + changed: boolean; + actions: Array<{ key: string }>; + }; + }; + expect(payload.fix.changed).toBe(false); + expect(payload.fix.actions).toEqual([]); + }); }); From 3cc6b4d99278e0a1fb714448bf5af93b8cb2ae0e Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 12:08:48 +0800 Subject: [PATCH 061/376] add missing auth status assertions --- test/codex-manager-cli.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 24200c28..99f9bff6 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -643,7 +643,11 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "status"]); expect(exitCode).toBe(0); + expect(setStoragePathMock).toHaveBeenCalledWith(null); expect(logSpy).toHaveBeenCalledWith("Accounts (2)"); + expect(logSpy).toHaveBeenCalledWith( + "Storage: /mock/openai-codex-accounts.json", + ); expect(logSpy).toHaveBeenCalledWith( expect.stringContaining("1. 1. active@example.com [current]"), ); From ca9f9a6db558c810e636f160d2f1939e7a389c6c Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 12:10:06 +0800 Subject: [PATCH 062/376] refactor: extract storage path state --- lib/storage.ts | 29 ++++------------------------- lib/storage/path-state.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 25 deletions(-) create mode 100644 lib/storage/path-state.ts diff --git a/lib/storage.ts b/lib/storage.ts index bd98cfd1..a70da79d 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -41,6 +41,10 @@ import { migrateV1ToV3, type RateLimitStateV3, } from "./storage/migrations.js"; +import { + getStoragePathState, + setStoragePathState, +} from "./storage/path-state.js"; import { findProjectRoot, getConfigDir, @@ -334,31 +338,6 @@ async function ensureGitignore(storagePath: string): Promise { } } -type StoragePathState = { - currentStoragePath: string | null; - currentLegacyProjectStoragePath: string | null; - currentLegacyWorktreeStoragePath: string | null; - currentProjectRoot: string | null; -}; - -let currentStorageState: StoragePathState = { - currentStoragePath: null, - currentLegacyProjectStoragePath: null, - currentLegacyWorktreeStoragePath: null, - currentProjectRoot: null, -}; - -const storagePathStateContext = new AsyncLocalStorage(); - -function getStoragePathState(): StoragePathState { - return storagePathStateContext.getStore() ?? currentStorageState; -} - -function setStoragePathState(state: StoragePathState): void { - currentStorageState = state; - storagePathStateContext.enterWith(state); -} - export function setStorageBackupEnabled(enabled: boolean): void { storageBackupEnabled = enabled; } diff --git a/lib/storage/path-state.ts b/lib/storage/path-state.ts new file mode 100644 index 00000000..6c708d2c --- /dev/null +++ b/lib/storage/path-state.ts @@ -0,0 +1,26 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +export type StoragePathState = { + currentStoragePath: string | null; + currentLegacyProjectStoragePath: string | null; + currentLegacyWorktreeStoragePath: string | null; + currentProjectRoot: string | null; +}; + +const storagePathStateContext = new AsyncLocalStorage(); + +let currentStorageState: StoragePathState = { + currentStoragePath: null, + currentLegacyProjectStoragePath: null, + currentLegacyWorktreeStoragePath: null, + currentProjectRoot: null, +}; + +export function getStoragePathState(): StoragePathState { + return storagePathStateContext.getStore() ?? currentStorageState; +} + +export function setStoragePathState(state: StoragePathState): void { + currentStorageState = state; + storagePathStateContext.enterWith(state); +} From 99f8a26b6d537e331fa9b3cfd340f7a79112b705 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 12:15:58 +0800 Subject: [PATCH 063/376] guard doctor sync after transaction abort --- lib/codex-manager/repair-commands.ts | 9 +-- test/repair-commands.test.ts | 83 ++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/lib/codex-manager/repair-commands.ts b/lib/codex-manager/repair-commands.ts index 62bd6a7c..915df46d 100644 --- a/lib/codex-manager/repair-commands.ts +++ b/lib/codex-manager/repair-commands.ts @@ -644,12 +644,7 @@ function hasPlaceholderEmail(value: string | undefined): boolean { if (!value) return false; const email = value.trim().toLowerCase(); if (!email) return false; - return ( - email.endsWith("@example.com") - || email.includes("account1@example.com") - || email.includes("account2@example.com") - || email.includes("account3@example.com") - ); + return email.endsWith("@example.com"); } function normalizeDoctorIndexes(storage: AccountStorageV3): boolean { @@ -2003,7 +1998,7 @@ export async function runDoctor( }); } - if (pendingCodexActiveSync) { + if (pendingCodexActiveSync && (!doctorRefreshMutation || storageFixChanged)) { const synced = await setCodexCliActiveSelection(pendingCodexActiveSync); if (synced) { supplementalFixActions.push({ diff --git a/test/repair-commands.test.ts b/test/repair-commands.test.ts index 907facb2..f352a94f 100644 --- a/test/repair-commands.test.ts +++ b/test/repair-commands.test.ts @@ -765,4 +765,87 @@ describe("repair-commands direct deps coverage", () => { expect(payload.fix.changed).toBe(false); expect(payload.fix.actions).toEqual([]); }); + + it("runDoctor skips Codex sync when the refreshed account disappears before persistence", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + accounts: [ + { + email: "doctor@example.com", + refreshToken: "doctor-refresh", + accessToken: "doctor-access", + expiresAt: now - 60_000, + accountId: "doctor-account", + accountIdSource: "manual" as const, + enabled: true, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }); + withAccountStorageTransactionMock.mockImplementation(async (handler) => + handler( + { + version: 3, + accounts: [ + { + email: "remaining@example.com", + refreshToken: "remaining-refresh", + accessToken: "remaining-access", + expiresAt: now + 60_000, + accountId: "remaining-account", + accountIdSource: "manual" as const, + enabled: true, + }, + ], + activeIndex: 0, + activeIndexByFamily: { + codex: 0, + "codex-max": 0, + "gpt-5-codex": 0, + "gpt-5.1": 0, + "gpt-5.2": 0, + }, + }, + async () => undefined, + ), + ); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "doctor-access-next", + refresh: "doctor-refresh-next", + expires: now + 3_600_000, + idToken: "doctor-id-next", + }); + extractAccountEmailMock.mockImplementation((accessToken: string | undefined) => + accessToken === "doctor-access-next" ? "doctor-fresh@example.com" : "doctor@example.com" + ); + extractAccountIdMock.mockImplementation((accessToken: string | undefined) => + accessToken === "doctor-access-next" ? "doctor-token-account" : "doctor-account" + ); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const exitCode = await runDoctor( + ["--json", "--fix"], + createDeps({ + hasUsableAccessToken: () => false, + resolveActiveIndex: () => -1, + }), + ); + + expect(exitCode).toBe(1); + expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); + const payload = JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")) as { + fix: { + changed: boolean; + actions: Array<{ key: string }>; + }; + }; + expect(payload.fix.changed).toBe(true); + expect(payload.fix.actions).not.toContainEqual( + expect.objectContaining({ key: "codex-active-sync" }), + ); + }); }); From 45cda798ab410a2c9fd3b4ce788034f47a797691 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 12:16:06 +0800 Subject: [PATCH 064/376] refactor: extract named backup discovery --- lib/storage.ts | 89 +++++------------------------------- lib/storage/named-backups.ts | 85 ++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 78 deletions(-) create mode 100644 lib/storage/named-backups.ts diff --git a/lib/storage.ts b/lib/storage.ts index a70da79d..ec257578 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -11,14 +11,18 @@ import { } from "./named-backup-export.js"; import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; import { AnyAccountStorageSchema, getValidationErrors } from "./schemas.js"; - import { formatStorageErrorHint } from "./storage/error-hints.js"; +import { + collectNamedBackups, + type NamedBackupSummary, +} from "./storage/named-backups.js"; export { formatStorageErrorHint } from "./storage/error-hints.js"; export { getAccountIdentityKey, normalizeEmailKey, } from "./storage/identity.js"; +export type { NamedBackupSummary } from "./storage/named-backups.js"; import { getFlaggedAccountsPath as buildFlaggedAccountsPath, @@ -139,82 +143,6 @@ export type RestoreAssessment = { backupMetadata: BackupMetadata; }; -export interface NamedBackupSummary { - path: string; - fileName: string; - accountCount: number; - mtimeMs: number; -} - -async function collectNamedBackups( - storagePath: string, -): Promise { - const backupRoot = getNamedBackupRoot(storagePath); - let entries: Array<{ isFile(): boolean; name: string }>; - try { - entries = await fs.readdir(backupRoot, { - withFileTypes: true, - encoding: "utf8", - }); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") return []; - throw error; - } - - const candidates: NamedBackupSummary[] = []; - for (const entry of entries) { - if (!entry.isFile()) continue; - if (!entry.name.toLowerCase().endsWith(".json")) continue; - const candidatePath = join(backupRoot, entry.name); - try { - const statsBefore = await fs.stat(candidatePath); - const { normalized } = await loadAccountsFromPath(candidatePath); - if (!normalized || normalized.accounts.length === 0) continue; - const statsAfter = await fs.stat(candidatePath).catch(() => null); - if (statsAfter && statsAfter.mtimeMs !== statsBefore.mtimeMs) { - log.debug( - "backup file changed between stat and load, mtime may be stale", - { - candidatePath, - fileName: entry.name, - beforeMtimeMs: statsBefore.mtimeMs, - afterMtimeMs: statsAfter.mtimeMs, - }, - ); - } - candidates.push({ - path: candidatePath, - fileName: entry.name, - accountCount: normalized.accounts.length, - mtimeMs: statsBefore.mtimeMs, - }); - } catch (error) { - log.debug( - "Skipping named backup candidate after loadAccountsFromPath/fs.stat failure", - { - candidatePath, - fileName: entry.name, - error: - error instanceof Error - ? { - message: error.message, - stack: error.stack, - } - : String(error), - }, - ); - } - } - - candidates.sort((left, right) => { - const mtimeDelta = right.mtimeMs - left.mtimeMs; - if (mtimeDelta !== 0) return mtimeDelta; - return left.fileName.localeCompare(right.fileName); - }); - return candidates; -} - /** * Custom error class for storage operations with platform-aware hints. */ @@ -868,7 +796,12 @@ export function buildNamedBackupPath(name: string): string { } export async function getNamedBackups(): Promise { - return collectNamedBackups(getStoragePath()); + return collectNamedBackups(getStoragePath(), { + readDir: fs.readdir, + stat: fs.stat, + loadAccountsFromPath, + logDebug: (message, meta) => log.debug(message, meta), + }); } export async function restoreAccountsFromBackup( diff --git a/lib/storage/named-backups.ts b/lib/storage/named-backups.ts new file mode 100644 index 00000000..a14a30c2 --- /dev/null +++ b/lib/storage/named-backups.ts @@ -0,0 +1,85 @@ +import { join } from "node:path"; +import { getNamedBackupRoot } from "../named-backup-export.js"; + +export interface NamedBackupSummary { + path: string; + fileName: string; + accountCount: number; + mtimeMs: number; +} + +export interface CollectNamedBackupsDeps { + readDir: typeof import("node:fs").promises.readdir; + stat: typeof import("node:fs").promises.stat; + loadAccountsFromPath: ( + path: string, + ) => Promise<{ normalized: { accounts: unknown[] } | null }>; + logDebug?: (message: string, meta: Record) => void; +} + +export async function collectNamedBackups( + storagePath: string, + deps: CollectNamedBackupsDeps, +): Promise { + const backupRoot = getNamedBackupRoot(storagePath); + let entries: Array<{ isFile(): boolean; name: string }>; + try { + entries = await deps.readDir(backupRoot, { + withFileTypes: true, + encoding: "utf8", + }); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") return []; + throw error; + } + + const candidates: NamedBackupSummary[] = []; + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!entry.name.toLowerCase().endsWith(".json")) continue; + const candidatePath = join(backupRoot, entry.name); + try { + const statsBefore = await deps.stat(candidatePath); + const { normalized } = await deps.loadAccountsFromPath(candidatePath); + if (!normalized || normalized.accounts.length === 0) continue; + const statsAfter = await deps.stat(candidatePath).catch(() => null); + if (statsAfter && statsAfter.mtimeMs !== statsBefore.mtimeMs) { + deps.logDebug?.( + "backup file changed between stat and load, mtime may be stale", + { + candidatePath, + fileName: entry.name, + beforeMtimeMs: statsBefore.mtimeMs, + afterMtimeMs: statsAfter.mtimeMs, + }, + ); + } + candidates.push({ + path: candidatePath, + fileName: entry.name, + accountCount: normalized.accounts.length, + mtimeMs: statsBefore.mtimeMs, + }); + } catch (error) { + deps.logDebug?.( + "Skipping named backup candidate after loadAccountsFromPath/fs.stat failure", + { + candidatePath, + fileName: entry.name, + error: + error instanceof Error + ? { message: error.message, stack: error.stack } + : String(error), + }, + ); + } + } + + candidates.sort((left, right) => { + const mtimeDelta = right.mtimeMs - left.mtimeMs; + if (mtimeDelta !== 0) return mtimeDelta; + return left.fileName.localeCompare(right.fileName); + }); + return candidates; +} From d6eb35d020e9647a92c650bce7ef35ac1a1bf76e Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 12:19:16 +0800 Subject: [PATCH 065/376] align auth status test labels with cli output --- test/codex-manager-cli.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 99f9bff6..f0f6b13b 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -82,7 +82,9 @@ vi.mock("../lib/accounts.js", () => ({ extractAccountEmail: vi.fn(() => undefined), extractAccountId: vi.fn(() => "acc_test"), formatAccountLabel: vi.fn((account: { email?: string }, index: number) => - account.email ? `${index + 1}. ${account.email}` : `Account ${index + 1}`, + account.email + ? `Account ${index + 1} (${account.email})` + : `Account ${index + 1}`, ), formatCooldown: vi.fn(() => null), formatWaitTime: vi.fn( @@ -649,10 +651,12 @@ describe("codex manager cli commands", () => { "Storage: /mock/openai-codex-accounts.json", ); expect(logSpy).toHaveBeenCalledWith( - expect.stringContaining("1. 1. active@example.com [current]"), + expect.stringContaining("1. Account 1 (active@example.com) [current]"), ); expect(logSpy).toHaveBeenCalledWith( - expect.stringContaining("2. 2. disabled@example.com [disabled, rate-limited]"), + expect.stringContaining( + "2. Account 2 (disabled@example.com) [disabled, rate-limited]", + ), ); }); From d949584c23ec2f99088034d7c7d96237492d4749 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 12:19:19 +0800 Subject: [PATCH 066/376] refactor: extract runtime metrics helpers --- index.ts | 7761 ++++++++++++++++++++++------------------ lib/runtime/metrics.ts | 124 + 2 files changed, 4415 insertions(+), 3470 deletions(-) create mode 100644 lib/runtime/metrics.ts diff --git a/index.ts b/index.ts index 368daaf3..ca933145 100644 --- a/index.ts +++ b/index.ts @@ -23,171 +23,195 @@ */ -import { tool } from "@codex-ai/plugin/tool"; import type { Plugin, PluginInput } from "@codex-ai/plugin"; +import { tool } from "@codex-ai/plugin/tool"; import type { Auth } from "@codex-ai/sdk"; import { - createAuthorizationFlow, - exchangeAuthorizationCode, - parseAuthorizationInput, - redactOAuthUrlForLog, - REDIRECT_URI, + AccountManager, + extractAccountEmail, + extractAccountId, + formatAccountLabel, + formatCooldown, + formatWaitTime, + getAccountIdCandidates, + isCodexCliSyncEnabled, + lookupCodexCliTokensByEmail, + parseRateLimitReason, + resolveRequestAccountId, + resolveRuntimeRequestIdentity, + sanitizeEmail, + selectBestAccountCandidate, + shouldUpdateAccountIdFromToken, + type Workspace, +} from "./lib/accounts.js"; +import { + createAuthorizationFlow, + exchangeAuthorizationCode, + parseAuthorizationInput, + REDIRECT_URI, + redactOAuthUrlForLog, } from "./lib/auth/auth.js"; -import { queuedRefresh } from "./lib/refresh-queue.js"; -import { isBrowserLaunchSuppressed, openBrowserUrl } from "./lib/auth/browser.js"; +import { + isBrowserLaunchSuppressed, + openBrowserUrl, +} from "./lib/auth/browser.js"; import { startLocalOAuthServer } from "./lib/auth/server.js"; +import { checkAndNotify } from "./lib/auto-update-checker.js"; +import { CapabilityPolicyStore } from "./lib/capability-policy.js"; import { promptAddAnotherAccount, promptLoginMode } from "./lib/cli.js"; import { - getCodexMode, - getFastSession, - getFastSessionStrategy, - getFastSessionMaxInputItems, - getRateLimitToastDebounceMs, - getRetryAllAccountsMaxRetries, - getRetryAllAccountsMaxWaitMs, - getRetryAllAccountsRateLimited, - getFallbackToGpt52OnUnsupportedGpt53, - getUnsupportedCodexPolicy, - getUnsupportedCodexFallbackChain, - getTokenRefreshSkewMs, - getSessionRecovery, getAutoResume, - getToastDurationMs, - getPerProjectAccounts, + getCodexMode, + getCodexTuiColorProfile, + getCodexTuiGlyphMode, + getCodexTuiV2, getEmptyResponseMaxRetries, getEmptyResponseRetryDelayMs, - getPidOffsetEnabled, + getFallbackToGpt52OnUnsupportedGpt53, + getFastSession, + getFastSessionMaxInputItems, + getFastSessionStrategy, getFetchTimeoutMs, - getStreamStallTimeoutMs, - getCodexTuiV2, - getCodexTuiColorProfile, - getCodexTuiGlyphMode, getLiveAccountSync, getLiveAccountSyncDebounceMs, getLiveAccountSyncPollMs, - getSessionAffinity, - getSessionAffinityTtlMs, - getSessionAffinityMaxEntries, - getProactiveRefreshGuardian, - getProactiveRefreshIntervalMs, - getProactiveRefreshBufferMs, getNetworkErrorCooldownMs, - getServerErrorCooldownMs, - getStorageBackupEnabled, + getPerProjectAccounts, + getPidOffsetEnabled, getPreemptiveQuotaEnabled, + getPreemptiveQuotaMaxDeferralMs, getPreemptiveQuotaRemainingPercent5h, getPreemptiveQuotaRemainingPercent7d, - getPreemptiveQuotaMaxDeferralMs, + getProactiveRefreshBufferMs, + getProactiveRefreshGuardian, + getProactiveRefreshIntervalMs, + getRateLimitToastDebounceMs, + getRetryAllAccountsMaxRetries, + getRetryAllAccountsMaxWaitMs, + getRetryAllAccountsRateLimited, + getServerErrorCooldownMs, + getSessionAffinity, + getSessionAffinityMaxEntries, + getSessionAffinityTtlMs, + getSessionRecovery, + getStorageBackupEnabled, + getStreamStallTimeoutMs, + getToastDurationMs, + getTokenRefreshSkewMs, + getUnsupportedCodexFallbackChain, + getUnsupportedCodexPolicy, loadPluginConfig, } from "./lib/config.js"; import { - AUTH_LABELS, - CODEX_BASE_URL, - DUMMY_API_KEY, - LOG_STAGES, - PLUGIN_NAME, - PROVIDER_ID, - ACCOUNT_LIMITS, + ACCOUNT_LIMITS, + AUTH_LABELS, + CODEX_BASE_URL, + DUMMY_API_KEY, + LOG_STAGES, + PLUGIN_NAME, + PROVIDER_ID, } from "./lib/constants.js"; +import { handleContextOverflow } from "./lib/context-overflow.js"; +import { + EntitlementCache, + resolveEntitlementAccountKey, +} from "./lib/entitlement-cache.js"; +import { LiveAccountSync } from "./lib/live-account-sync.js"; import { + clearCorrelationId, initLogger, - logRequest, logDebug, + logError, logInfo, + logRequest, logWarn, - logError, setCorrelationId, - clearCorrelationId, } from "./lib/logger.js"; -import { checkAndNotify } from "./lib/auto-update-checker.js"; -import { handleContextOverflow } from "./lib/context-overflow.js"; import { - AccountManager, - getAccountIdCandidates, - extractAccountEmail, - extractAccountId, - formatAccountLabel, - formatCooldown, - formatWaitTime, - resolveRuntimeRequestIdentity, - sanitizeEmail, - selectBestAccountCandidate, - shouldUpdateAccountIdFromToken, - resolveRequestAccountId, - parseRateLimitReason, - lookupCodexCliTokensByEmail, - isCodexCliSyncEnabled, - type Workspace, -} from "./lib/accounts.js"; + PreemptiveQuotaScheduler, + readQuotaSchedulerSnapshot, +} from "./lib/preemptive-quota-scheduler.js"; import { - getStoragePath, - loadAccounts, - saveAccounts, - withAccountStorageTransaction, - clearAccounts, - setStoragePath, - exportAccounts, - importAccounts, - loadFlaggedAccounts, - saveFlaggedAccounts, - clearFlaggedAccounts, - findMatchingAccountIndex, - StorageError, - formatStorageErrorHint, - setStorageBackupEnabled, - type AccountStorageV3, - type FlaggedAccountMetadataV1, -} from "./lib/storage.js"; + getCodexInstructions, + getModelFamily, + MODEL_FAMILIES, + type ModelFamily, + prewarmCodexInstructions, +} from "./lib/prompts/codex.js"; +import { prewarmHostCodexPrompt } from "./lib/prompts/host-codex-prompt.js"; +import { + createSessionRecoveryHook, + detectErrorType, + getRecoveryToastContent, + isRecoverableError, +} from "./lib/recovery.js"; +import { RefreshGuardian } from "./lib/refresh-guardian.js"; +import { queuedRefresh } from "./lib/refresh-queue.js"; +import { + evaluateFailurePolicy, + type FailoverMode, +} from "./lib/request/failure-policy.js"; import { applyProxyCompatibleInit, createCodexHeaders, extractRequestUrl, - handleErrorResponse, - handleSuccessResponse, getUnsupportedCodexModelInfo, + handleErrorResponse, + handleSuccessResponse, + isWorkspaceDisabledError, + refreshAndUpdateToken, resolveUnsupportedCodexFallbackModel, - refreshAndUpdateToken, - rewriteUrlForCodex, + rewriteUrlForCodex, shouldRefreshToken, transformRequestForCodex, - isWorkspaceDisabledError, } from "./lib/request/fetch-helpers.js"; -import { applyFastSessionDefaults } from "./lib/request/request-transformer.js"; import { getRateLimitBackoff, RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS, resetRateLimitBackoff, } from "./lib/request/rate-limit-backoff.js"; +import { applyFastSessionDefaults } from "./lib/request/request-transformer.js"; import { isEmptyResponse } from "./lib/request/response-handler.js"; +import { withStreamingFailover } from "./lib/request/stream-failover.js"; import { addJitter } from "./lib/rotation.js"; -import { SessionAffinityStore } from "./lib/session-affinity.js"; -import { LiveAccountSync } from "./lib/live-account-sync.js"; -import { RefreshGuardian } from "./lib/refresh-guardian.js"; import { - evaluateFailurePolicy, - type FailoverMode, -} from "./lib/request/failure-policy.js"; + createRuntimeMetrics, + parseEnvInt, + parseFailoverMode, + parseRetryAfterHintMs, + type RuntimeMetrics, + sanitizeResponseHeadersForLog, +} from "./lib/runtime/metrics.js"; +import { SessionAffinityStore } from "./lib/session-affinity.js"; +import { registerCleanup } from "./lib/shutdown.js"; import { - EntitlementCache, - resolveEntitlementAccountKey, -} from "./lib/entitlement-cache.js"; + type AccountStorageV3, + clearAccounts, + clearFlaggedAccounts, + exportAccounts, + type FlaggedAccountMetadataV1, + findMatchingAccountIndex, + formatStorageErrorHint, + getStoragePath, + importAccounts, + loadAccounts, + loadFlaggedAccounts, + StorageError, + saveAccounts, + saveFlaggedAccounts, + setStorageBackupEnabled, + setStoragePath, + withAccountStorageTransaction, +} from "./lib/storage.js"; import { - PreemptiveQuotaScheduler, - readQuotaSchedulerSnapshot, -} from "./lib/preemptive-quota-scheduler.js"; -import { CapabilityPolicyStore } from "./lib/capability-policy.js"; -import { withStreamingFailover } from "./lib/request/stream-failover.js"; -import { buildTableHeader, buildTableRow, type TableOptions } from "./lib/table-formatter.js"; -import { setUiRuntimeOptions, type UiRuntimeOptions } from "./lib/ui/runtime.js"; -import { paintUiText, formatUiBadge, formatUiHeader, formatUiItem, formatUiKeyValue, formatUiSection } from "./lib/ui/format.js"; + buildTableHeader, + buildTableRow, + type TableOptions, +} from "./lib/table-formatter.js"; import { - getModelFamily, - getCodexInstructions, - MODEL_FAMILIES, - prewarmCodexInstructions, - type ModelFamily, -} from "./lib/prompts/codex.js"; -import { prewarmHostCodexPrompt } from "./lib/prompts/host-codex-prompt.js"; + createHashlineEditTool, + createHashlineReadTool, +} from "./lib/tools/hashline-tools.js"; import type { AccountIdSource, OAuthAuthDetails, @@ -196,16 +220,17 @@ import type { UserConfig, } from "./lib/types.js"; import { - createSessionRecoveryHook, - isRecoverableError, - detectErrorType, - getRecoveryToastContent, -} from "./lib/recovery.js"; + formatUiBadge, + formatUiHeader, + formatUiItem, + formatUiKeyValue, + formatUiSection, + paintUiText, +} from "./lib/ui/format.js"; import { - createHashlineEditTool, - createHashlineReadTool, -} from "./lib/tools/hashline-tools.js"; -import { registerCleanup } from "./lib/shutdown.js"; + setUiRuntimeOptions, + type UiRuntimeOptions, +} from "./lib/ui/runtime.js"; /** * OpenAI Codex OAuth authentication plugin for Codex CLI host runtime @@ -236,7 +261,8 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { let liveAccountSyncPath: string | null = null; let refreshGuardian: RefreshGuardian | null = null; let refreshGuardianConfigKey: string | null = null; - let sessionAffinityStore: SessionAffinityStore | null = new SessionAffinityStore(); + let sessionAffinityStore: SessionAffinityStore | null = + new SessionAffinityStore(); let sessionAffinityConfigKey: string | null = null; const entitlementCache = new EntitlementCache(); const preemptiveQuotaScheduler = new PreemptiveQuotaScheduler(); @@ -256,668 +282,591 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { conservative: 20_000, }; - const parseFailoverMode = (value: string | undefined): FailoverMode => { - const normalized = (value ?? "").trim().toLowerCase(); - if (normalized === "aggressive") return "aggressive"; - if (normalized === "conservative") return "conservative"; - return "balanced"; - }; - - const parseEnvInt = (value: string | undefined): number | undefined => { - if (value === undefined) return undefined; - const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) ? parsed : undefined; - }; + const runtimeMetrics: RuntimeMetrics = createRuntimeMetrics(); - const MAX_RETRY_HINT_MS = 5 * 60 * 1000; - const clampRetryHintMs = (value: number): number | null => { - if (!Number.isFinite(value)) return null; - const normalized = Math.floor(value); - if (normalized <= 0) return null; - return Math.min(normalized, MAX_RETRY_HINT_MS); - }; + type TokenSuccess = Extract; + type TokenSuccessWithAccount = TokenSuccess & { + accountIdOverride?: string; + accountIdSource?: AccountIdSource; + accountLabel?: string; + workspaces?: Workspace[]; + }; - const parseRetryAfterHintMs = (headers: Headers): number | null => { - const retryAfterMsHeader = headers.get("retry-after-ms")?.trim(); - if (retryAfterMsHeader && /^\d+$/.test(retryAfterMsHeader)) { - return clampRetryHintMs(Number.parseInt(retryAfterMsHeader, 10)); - } + const resolveAccountSelection = ( + tokens: TokenSuccess, + ): TokenSuccessWithAccount => { + const override = (process.env.CODEX_AUTH_ACCOUNT_ID ?? "").trim(); + if (override) { + const suffix = override.length > 6 ? override.slice(-6) : override; + logInfo( + `Using account override from CODEX_AUTH_ACCOUNT_ID (id:${suffix}).`, + ); + return { + ...tokens, + accountIdOverride: override, + accountIdSource: "manual", + accountLabel: `Override [id:${suffix}]`, + }; + } - const retryAfterHeader = headers.get("retry-after")?.trim(); - if (retryAfterHeader && /^\d+$/.test(retryAfterHeader)) { - return clampRetryHintMs(Number.parseInt(retryAfterHeader, 10) * 1000); - } - if (retryAfterHeader) { - const retryAtMs = Date.parse(retryAfterHeader); - if (Number.isFinite(retryAtMs)) { - return clampRetryHintMs(retryAtMs - Date.now()); - } - } + const candidates = getAccountIdCandidates(tokens.access, tokens.idToken); + if (candidates.length === 0) { + return tokens; + } - const resetAtHeader = headers.get("x-ratelimit-reset")?.trim(); - if (resetAtHeader && /^\d+$/.test(resetAtHeader)) { - const resetRaw = Number.parseInt(resetAtHeader, 10); - const resetAtMs = resetRaw < 10_000_000_000 ? resetRaw * 1000 : resetRaw; - return clampRetryHintMs(resetAtMs - Date.now()); + // Convert candidates to workspaces + const workspaces: Workspace[] = candidates.map((c) => ({ + id: c.accountId, + name: c.label, + enabled: true, + isDefault: c.isDefault, + })); + + if (candidates.length === 1) { + const [candidate] = candidates; + if (candidate) { + return { + ...tokens, + accountIdOverride: candidate.accountId, + accountIdSource: candidate.source, + accountLabel: candidate.label, + workspaces, + }; } - - return null; - }; - - const sanitizeResponseHeadersForLog = (headers: Headers): Record => { - const allowed = new Set([ - "content-type", - "x-request-id", - "x-openai-request-id", - "x-codex-plan-type", - "x-codex-active-limit", - "x-codex-primary-used-percent", - "x-codex-primary-window-minutes", - "x-codex-primary-reset-at", - "x-codex-primary-reset-after-seconds", - "x-codex-secondary-used-percent", - "x-codex-secondary-window-minutes", - "x-codex-secondary-reset-at", - "x-codex-secondary-reset-after-seconds", - "retry-after", - "x-ratelimit-reset", - "x-ratelimit-reset-requests", - ]); - const sanitized: Record = {}; - for (const [rawName, rawValue] of headers.entries()) { - const name = rawName.toLowerCase(); - if (!allowed.has(name)) continue; - sanitized[name] = rawValue; } - return sanitized; - }; - type RuntimeMetrics = { - startedAt: number; - totalRequests: number; - successfulRequests: number; - failedRequests: number; - rateLimitedResponses: number; - serverErrors: number; - networkErrors: number; - userAborts: number; - authRefreshFailures: number; - emptyResponseRetries: number; - accountRotations: number; - sameAccountRetries: number; - streamFailoverAttempts: number; - streamFailoverRecoveries: number; - streamFailoverCrossAccountRecoveries: number; - cumulativeLatencyMs: number; - lastRequestAt: number | null; - lastError: string | null; - }; + // Auto-select the best workspace candidate without prompting. + // This honors org/default/id-token signals and avoids forcing personal token IDs. + const choice = selectBestAccountCandidate(candidates); + if (!choice) return tokens; - const runtimeMetrics: RuntimeMetrics = { - startedAt: Date.now(), - totalRequests: 0, - successfulRequests: 0, - failedRequests: 0, - rateLimitedResponses: 0, - serverErrors: 0, - networkErrors: 0, - userAborts: 0, - authRefreshFailures: 0, - emptyResponseRetries: 0, - accountRotations: 0, - sameAccountRetries: 0, - streamFailoverAttempts: 0, - streamFailoverRecoveries: 0, - streamFailoverCrossAccountRecoveries: 0, - cumulativeLatencyMs: 0, - lastRequestAt: null, - lastError: null, + return { + ...tokens, + accountIdOverride: choice.accountId, + accountIdSource: choice.source ?? "token", + accountLabel: choice.label, + workspaces, + }; }; - type TokenSuccess = Extract; - type TokenSuccessWithAccount = TokenSuccess & { - accountIdOverride?: string; - accountIdSource?: AccountIdSource; - accountLabel?: string; - workspaces?: Workspace[]; - }; - - const resolveAccountSelection = ( - tokens: TokenSuccess, - ): TokenSuccessWithAccount => { - const override = (process.env.CODEX_AUTH_ACCOUNT_ID ?? "").trim(); - if (override) { - const suffix = override.length > 6 ? override.slice(-6) : override; - logInfo(`Using account override from CODEX_AUTH_ACCOUNT_ID (id:${suffix}).`); - return { - ...tokens, - accountIdOverride: override, - accountIdSource: "manual", - accountLabel: `Override [id:${suffix}]`, - }; - } - - const candidates = getAccountIdCandidates(tokens.access, tokens.idToken); - if (candidates.length === 0) { - return tokens; - } - - // Convert candidates to workspaces - const workspaces: Workspace[] = candidates.map((c) => ({ - id: c.accountId, - name: c.label, - enabled: true, - isDefault: c.isDefault, - })); - - if (candidates.length === 1) { - const [candidate] = candidates; - if (candidate) { - return { - ...tokens, - accountIdOverride: candidate.accountId, - accountIdSource: candidate.source, - accountLabel: candidate.label, - workspaces, - }; + const buildManualOAuthFlow = ( + pkce: { verifier: string }, + url: string, + expectedState: string, + onSuccess?: (tokens: TokenSuccessWithAccount) => Promise, + ) => ({ + url, + method: "code" as const, + instructions: AUTH_LABELS.INSTRUCTIONS_MANUAL, + validate: (input: string): string | undefined => { + const parsed = parseAuthorizationInput(input); + if (!parsed.code) { + return `No authorization code found. Paste the full callback URL (e.g., ${REDIRECT_URI}?code=...)`; + } + if (!parsed.state) { + return "Missing OAuth state. Paste the full callback URL including both code and state parameters."; + } + if (parsed.state !== expectedState) { + return "OAuth state mismatch. Restart login and paste the callback URL generated for this login attempt."; + } + return undefined; + }, + callback: async (input: string) => { + const parsed = parseAuthorizationInput(input); + if (!parsed.code || !parsed.state) { + return { + type: "failed" as const, + reason: "invalid_response" as const, + message: "Missing authorization code or OAuth state", + }; + } + if (parsed.state !== expectedState) { + return { + type: "failed" as const, + reason: "invalid_response" as const, + message: "OAuth state mismatch. Restart login and try again.", + }; + } + const tokens = await exchangeAuthorizationCode( + parsed.code, + pkce.verifier, + REDIRECT_URI, + ); + if (tokens?.type === "success") { + const resolved = resolveAccountSelection(tokens); + if (onSuccess) { + await onSuccess(resolved); } + return resolved; } - - // Auto-select the best workspace candidate without prompting. - // This honors org/default/id-token signals and avoids forcing personal token IDs. - const choice = selectBestAccountCandidate(candidates); - if (!choice) return tokens; - - return { - ...tokens, - accountIdOverride: choice.accountId, - accountIdSource: choice.source ?? "token", - accountLabel: choice.label, - workspaces, - }; - }; - - const buildManualOAuthFlow = ( - pkce: { verifier: string }, - url: string, - expectedState: string, - onSuccess?: (tokens: TokenSuccessWithAccount) => Promise, - ) => ({ - url, - method: "code" as const, - instructions: AUTH_LABELS.INSTRUCTIONS_MANUAL, - validate: (input: string): string | undefined => { - const parsed = parseAuthorizationInput(input); - if (!parsed.code) { - return `No authorization code found. Paste the full callback URL (e.g., ${REDIRECT_URI}?code=...)`; - } - if (!parsed.state) { - return "Missing OAuth state. Paste the full callback URL including both code and state parameters."; - } - if (parsed.state !== expectedState) { - return "OAuth state mismatch. Restart login and paste the callback URL generated for this login attempt."; - } - return undefined; - }, - callback: async (input: string) => { - const parsed = parseAuthorizationInput(input); - if (!parsed.code || !parsed.state) { - return { - type: "failed" as const, - reason: "invalid_response" as const, - message: "Missing authorization code or OAuth state", - }; - } - if (parsed.state !== expectedState) { - return { - type: "failed" as const, - reason: "invalid_response" as const, - message: "OAuth state mismatch. Restart login and try again.", - }; - } - const tokens = await exchangeAuthorizationCode( - parsed.code, - pkce.verifier, - REDIRECT_URI, - ); - if (tokens?.type === "success") { - const resolved = resolveAccountSelection(tokens); - if (onSuccess) { - await onSuccess(resolved); - } - return resolved; - } - return tokens?.type === "failed" - ? tokens - : { type: "failed" as const }; - }, - }); + return tokens?.type === "failed" ? tokens : { type: "failed" as const }; + }, + }); const runOAuthFlow = async ( forceNewLogin: boolean = false, ): Promise => { - const { pkce, state, url } = await createAuthorizationFlow({ forceNewLogin }); + const { pkce, state, url } = await createAuthorizationFlow({ + forceNewLogin, + }); logInfo(`OAuth URL: ${redactOAuthUrlForLog(url)}`); - let serverInfo: Awaited> | null = null; - try { - serverInfo = await startLocalOAuthServer({ state }); - } catch (err) { - logDebug(`[${PLUGIN_NAME}] Failed to start OAuth server: ${(err as Error)?.message ?? String(err)}`); - serverInfo = null; - } - openBrowserUrl(url); - - if (!serverInfo || !serverInfo.ready) { - serverInfo?.close(); - const message = - `\n[${PLUGIN_NAME}] OAuth callback server failed to start. ` + - `Please retry with "${AUTH_LABELS.OAUTH_MANUAL}".\n`; - logWarn(message); - return { type: "failed" as const }; - } - - const result = await serverInfo.waitForCode(state); - serverInfo.close(); + let serverInfo: Awaited> | null = + null; + try { + serverInfo = await startLocalOAuthServer({ state }); + } catch (err) { + logDebug( + `[${PLUGIN_NAME}] Failed to start OAuth server: ${(err as Error)?.message ?? String(err)}`, + ); + serverInfo = null; + } + openBrowserUrl(url); + + if (!serverInfo || !serverInfo.ready) { + serverInfo?.close(); + const message = + `\n[${PLUGIN_NAME}] OAuth callback server failed to start. ` + + `Please retry with "${AUTH_LABELS.OAUTH_MANUAL}".\n`; + logWarn(message); + return { type: "failed" as const }; + } + + const result = await serverInfo.waitForCode(state); + serverInfo.close(); if (!result) { - return { type: "failed" as const, reason: "unknown" as const, message: "OAuth callback timeout or cancelled" }; + return { + type: "failed" as const, + reason: "unknown" as const, + message: "OAuth callback timeout or cancelled", + }; } - return await exchangeAuthorizationCode( - result.code, - pkce.verifier, - REDIRECT_URI, - ); - }; - - const persistAccountPool = async ( - results: TokenSuccessWithAccount[], - replaceAll: boolean = false, - ): Promise => { - if (results.length === 0) return; - await withAccountStorageTransaction(async (loadedStorage, persist) => { - const now = Date.now(); - const stored = replaceAll ? null : loadedStorage; - const accounts = stored?.accounts ? [...stored.accounts] : []; - - for (const result of results) { - const accountId = result.accountIdOverride ?? extractAccountId(result.access); - const accountIdSource = - accountId - ? result.accountIdSource ?? - (result.accountIdOverride ? "manual" : "token") - : undefined; - const accountLabel = result.accountLabel; - const accountEmail = sanitizeEmail(extractAccountEmail(result.access, result.idToken)); - const existingIndex = findMatchingAccountIndex(accounts, { - accountId, - email: accountEmail, - refreshToken: result.refresh, - }, { - allowUniqueAccountIdFallbackWithoutEmail: true, - }); + return await exchangeAuthorizationCode( + result.code, + pkce.verifier, + REDIRECT_URI, + ); + }; - if (existingIndex === undefined) { - const initialWorkspaceIndex = - result.workspaces && result.workspaces.length > 0 - ? (() => { - if (accountId) { - const matchingWorkspaceIndex = result.workspaces.findIndex( - (workspace) => workspace.id === accountId, - ); - if (matchingWorkspaceIndex >= 0) { - return matchingWorkspaceIndex; - } - } - const firstEnabledWorkspaceIndex = result.workspaces.findIndex( - (workspace) => workspace.enabled !== false, - ); - return firstEnabledWorkspaceIndex >= 0 ? firstEnabledWorkspaceIndex : 0; - })() - : undefined; - accounts.push({ - accountId, - accountIdSource, - accountLabel, - email: accountEmail, - refreshToken: result.refresh, - accessToken: result.access, - expiresAt: result.expires, - addedAt: now, - lastUsed: now, - workspaces: result.workspaces, - currentWorkspaceIndex: initialWorkspaceIndex, - }); - continue; - } + const persistAccountPool = async ( + results: TokenSuccessWithAccount[], + replaceAll: boolean = false, + ): Promise => { + if (results.length === 0) return; + await withAccountStorageTransaction(async (loadedStorage, persist) => { + const now = Date.now(); + const stored = replaceAll ? null : loadedStorage; + const accounts = stored?.accounts ? [...stored.accounts] : []; + + for (const result of results) { + const accountId = + result.accountIdOverride ?? extractAccountId(result.access); + const accountIdSource = accountId + ? (result.accountIdSource ?? + (result.accountIdOverride ? "manual" : "token")) + : undefined; + const accountLabel = result.accountLabel; + const accountEmail = sanitizeEmail( + extractAccountEmail(result.access, result.idToken), + ); + const existingIndex = findMatchingAccountIndex( + accounts, + { + accountId, + email: accountEmail, + refreshToken: result.refresh, + }, + { + allowUniqueAccountIdFallbackWithoutEmail: true, + }, + ); - const existing = accounts[existingIndex]; - if (!existing) continue; - - const nextEmail = accountEmail ?? sanitizeEmail(existing.email); - const nextAccountId = accountId ?? existing.accountId; - const nextAccountIdSource = - accountId ? accountIdSource ?? existing.accountIdSource : existing.accountIdSource; - const nextAccountLabel = accountLabel ?? existing.accountLabel; - // Preserve tracked workspace state when auth refreshes do not return workspace metadata. - const mergedWorkspaces = result.workspaces - ? result.workspaces.map((newWs) => { - const existingWs = existing.workspaces?.find((w) => w.id === newWs.id); - return existingWs - ? { - ...newWs, - enabled: existingWs.enabled, - disabledAt: existingWs.disabledAt, - } - : newWs; - }) - : existing.workspaces; - const currentWorkspaceId = - existing.workspaces?.[ - typeof existing.currentWorkspaceIndex === "number" - ? existing.currentWorkspaceIndex - : 0 - ]?.id; - const nextCurrentWorkspaceIndex = - mergedWorkspaces && mergedWorkspaces.length > 0 - ? (() => { - if (currentWorkspaceId) { - const matchingWorkspaceIndex = mergedWorkspaces.findIndex( - (workspace) => workspace.id === currentWorkspaceId, - ); - if (matchingWorkspaceIndex >= 0) { - return matchingWorkspaceIndex; - } - } - const defaultWorkspaceIndex = mergedWorkspaces.findIndex( - (workspace) => workspace.isDefault === true, + if (existingIndex === undefined) { + const initialWorkspaceIndex = + result.workspaces && result.workspaces.length > 0 + ? (() => { + if (accountId) { + const matchingWorkspaceIndex = result.workspaces.findIndex( + (workspace) => workspace.id === accountId, ); - if (defaultWorkspaceIndex >= 0) { - return defaultWorkspaceIndex; + if (matchingWorkspaceIndex >= 0) { + return matchingWorkspaceIndex; } - const firstEnabledWorkspaceIndex = mergedWorkspaces.findIndex( + } + const firstEnabledWorkspaceIndex = + result.workspaces.findIndex( (workspace) => workspace.enabled !== false, ); - return firstEnabledWorkspaceIndex >= 0 ? firstEnabledWorkspaceIndex : 0; - })() - : existing.currentWorkspaceIndex; - accounts[existingIndex] = { - ...existing, - accountId: nextAccountId, - accountIdSource: nextAccountIdSource, - accountLabel: nextAccountLabel, - email: nextEmail, - refreshToken: result.refresh, - accessToken: result.access, - expiresAt: result.expires, - lastUsed: now, - workspaces: mergedWorkspaces, - currentWorkspaceIndex: nextCurrentWorkspaceIndex, - }; - } - - if (accounts.length === 0) return; + return firstEnabledWorkspaceIndex >= 0 + ? firstEnabledWorkspaceIndex + : 0; + })() + : undefined; + accounts.push({ + accountId, + accountIdSource, + accountLabel, + email: accountEmail, + refreshToken: result.refresh, + accessToken: result.access, + expiresAt: result.expires, + addedAt: now, + lastUsed: now, + workspaces: result.workspaces, + currentWorkspaceIndex: initialWorkspaceIndex, + }); + continue; + } - const activeIndex = replaceAll - ? 0 - : typeof stored?.activeIndex === "number" && Number.isFinite(stored.activeIndex) - ? stored.activeIndex - : 0; + const existing = accounts[existingIndex]; + if (!existing) continue; + + const nextEmail = accountEmail ?? sanitizeEmail(existing.email); + const nextAccountId = accountId ?? existing.accountId; + const nextAccountIdSource = accountId + ? (accountIdSource ?? existing.accountIdSource) + : existing.accountIdSource; + const nextAccountLabel = accountLabel ?? existing.accountLabel; + // Preserve tracked workspace state when auth refreshes do not return workspace metadata. + const mergedWorkspaces = result.workspaces + ? result.workspaces.map((newWs) => { + const existingWs = existing.workspaces?.find( + (w) => w.id === newWs.id, + ); + return existingWs + ? { + ...newWs, + enabled: existingWs.enabled, + disabledAt: existingWs.disabledAt, + } + : newWs; + }) + : existing.workspaces; + const currentWorkspaceId = + existing.workspaces?.[ + typeof existing.currentWorkspaceIndex === "number" + ? existing.currentWorkspaceIndex + : 0 + ]?.id; + const nextCurrentWorkspaceIndex = + mergedWorkspaces && mergedWorkspaces.length > 0 + ? (() => { + if (currentWorkspaceId) { + const matchingWorkspaceIndex = mergedWorkspaces.findIndex( + (workspace) => workspace.id === currentWorkspaceId, + ); + if (matchingWorkspaceIndex >= 0) { + return matchingWorkspaceIndex; + } + } + const defaultWorkspaceIndex = mergedWorkspaces.findIndex( + (workspace) => workspace.isDefault === true, + ); + if (defaultWorkspaceIndex >= 0) { + return defaultWorkspaceIndex; + } + const firstEnabledWorkspaceIndex = mergedWorkspaces.findIndex( + (workspace) => workspace.enabled !== false, + ); + return firstEnabledWorkspaceIndex >= 0 + ? firstEnabledWorkspaceIndex + : 0; + })() + : existing.currentWorkspaceIndex; + accounts[existingIndex] = { + ...existing, + accountId: nextAccountId, + accountIdSource: nextAccountIdSource, + accountLabel: nextAccountLabel, + email: nextEmail, + refreshToken: result.refresh, + accessToken: result.access, + expiresAt: result.expires, + lastUsed: now, + workspaces: mergedWorkspaces, + currentWorkspaceIndex: nextCurrentWorkspaceIndex, + }; + } - const clampedActiveIndex = Math.max(0, Math.min(activeIndex, accounts.length - 1)); - const activeIndexByFamily: Partial> = {}; - for (const family of MODEL_FAMILIES) { - const storedFamilyIndex = stored?.activeIndexByFamily?.[family]; - const rawFamilyIndex = replaceAll - ? 0 - : typeof storedFamilyIndex === "number" && Number.isFinite(storedFamilyIndex) - ? storedFamilyIndex - : clampedActiveIndex; - activeIndexByFamily[family] = Math.max( - 0, - Math.min(Math.floor(rawFamilyIndex), accounts.length - 1), - ); - } + if (accounts.length === 0) return; - await persist({ - version: 3, - accounts, - activeIndex: clampedActiveIndex, - activeIndexByFamily, - }); - }); - }; - - const showToast = async ( - message: string, - variant: "info" | "success" | "warning" | "error" = "success", - options?: { title?: string; duration?: number }, - ): Promise => { - try { - await client.tui.showToast({ - body: { - message, - variant, - ...(options?.title && { title: options.title }), - ...(options?.duration && { duration: options.duration }), - }, - }); - } catch { - // Ignore when TUI is not available. - } - }; - - const resolveActiveIndex = ( - storage: { - activeIndex: number; - activeIndexByFamily?: Partial>; - accounts: unknown[]; + const activeIndex = replaceAll + ? 0 + : typeof stored?.activeIndex === "number" && + Number.isFinite(stored.activeIndex) + ? stored.activeIndex + : 0; + + const clampedActiveIndex = Math.max( + 0, + Math.min(activeIndex, accounts.length - 1), + ); + const activeIndexByFamily: Partial> = {}; + for (const family of MODEL_FAMILIES) { + const storedFamilyIndex = stored?.activeIndexByFamily?.[family]; + const rawFamilyIndex = replaceAll + ? 0 + : typeof storedFamilyIndex === "number" && + Number.isFinite(storedFamilyIndex) + ? storedFamilyIndex + : clampedActiveIndex; + activeIndexByFamily[family] = Math.max( + 0, + Math.min(Math.floor(rawFamilyIndex), accounts.length - 1), + ); + } + + await persist({ + version: 3, + accounts, + activeIndex: clampedActiveIndex, + activeIndexByFamily, + }); + }); + }; + + const showToast = async ( + message: string, + variant: "info" | "success" | "warning" | "error" = "success", + options?: { title?: string; duration?: number }, + ): Promise => { + try { + await client.tui.showToast({ + body: { + message, + variant, + ...(options?.title && { title: options.title }), + ...(options?.duration && { duration: options.duration }), }, - family: ModelFamily = "codex", - ): number => { - const total = storage.accounts.length; - if (total === 0) return 0; - const rawCandidate = storage.activeIndexByFamily?.[family] ?? storage.activeIndex; - const raw = Number.isFinite(rawCandidate) ? rawCandidate : 0; - return Math.max(0, Math.min(raw, total - 1)); - }; + }); + } catch { + // Ignore when TUI is not available. + } + }; + + const resolveActiveIndex = ( + storage: { + activeIndex: number; + activeIndexByFamily?: Partial>; + accounts: unknown[]; + }, + family: ModelFamily = "codex", + ): number => { + const total = storage.accounts.length; + if (total === 0) return 0; + const rawCandidate = + storage.activeIndexByFamily?.[family] ?? storage.activeIndex; + const raw = Number.isFinite(rawCandidate) ? rawCandidate : 0; + return Math.max(0, Math.min(raw, total - 1)); + }; const hydrateEmails = async ( - storage: AccountStorageV3 | null, + storage: AccountStorageV3 | null, ): Promise => { - if (!storage) return storage; - const skipHydrate = - process.env.VITEST_WORKER_ID !== undefined || - process.env.NODE_ENV === "test" || - process.env.CODEX_SKIP_EMAIL_HYDRATE === "1"; - if (skipHydrate) return storage; - - const accountsCopy = storage.accounts.map((account) => - account ? { ...account } : account, - ); - const accountsToHydrate = accountsCopy.filter( - (account) => account && !account.email, - ); - if (accountsToHydrate.length === 0) return storage; - - let changed = false; - await Promise.all( - accountsToHydrate.map(async (account) => { - try { - const refreshed = await queuedRefresh(account.refreshToken); - if (refreshed.type !== "success") return; - const id = extractAccountId(refreshed.access); - const email = sanitizeEmail(extractAccountEmail(refreshed.access, refreshed.idToken)); - if ( - id && - id !== account.accountId && - shouldUpdateAccountIdFromToken(account.accountIdSource, account.accountId) - ) { - account.accountId = id; - account.accountIdSource = "token"; - changed = true; - } - if (email && email !== account.email) { - account.email = email; - changed = true; - } + if (!storage) return storage; + const skipHydrate = + process.env.VITEST_WORKER_ID !== undefined || + process.env.NODE_ENV === "test" || + process.env.CODEX_SKIP_EMAIL_HYDRATE === "1"; + if (skipHydrate) return storage; + + const accountsCopy = storage.accounts.map((account) => + account ? { ...account } : account, + ); + const accountsToHydrate = accountsCopy.filter( + (account) => account && !account.email, + ); + if (accountsToHydrate.length === 0) return storage; + + let changed = false; + await Promise.all( + accountsToHydrate.map(async (account) => { + try { + const refreshed = await queuedRefresh(account.refreshToken); + if (refreshed.type !== "success") return; + const id = extractAccountId(refreshed.access); + const email = sanitizeEmail( + extractAccountEmail(refreshed.access, refreshed.idToken), + ); + if ( + id && + id !== account.accountId && + shouldUpdateAccountIdFromToken( + account.accountIdSource, + account.accountId, + ) + ) { + account.accountId = id; + account.accountIdSource = "token"; + changed = true; + } + if (email && email !== account.email) { + account.email = email; + changed = true; + } if (refreshed.access && refreshed.access !== account.accessToken) { account.accessToken = refreshed.access; changed = true; } - if (typeof refreshed.expires === "number" && refreshed.expires !== account.expiresAt) { + if ( + typeof refreshed.expires === "number" && + refreshed.expires !== account.expiresAt + ) { account.expiresAt = refreshed.expires; changed = true; } - if (refreshed.refresh && refreshed.refresh !== account.refreshToken) { - account.refreshToken = refreshed.refresh; - changed = true; - } + if (refreshed.refresh && refreshed.refresh !== account.refreshToken) { + account.refreshToken = refreshed.refresh; + changed = true; + } } catch { logWarn(`[${PLUGIN_NAME}] Failed to hydrate email for account`); } - }), - ); - - if (changed) { - storage.accounts = accountsCopy; - await saveAccounts(storage); - } - return storage; - }; - - const getRateLimitResetTimeForFamily = ( - account: { rateLimitResetTimes?: Record }, - now: number, - family: ModelFamily, - ): number | null => { - const times = account.rateLimitResetTimes; - if (!times) return null; - - let minReset: number | null = null; - const prefix = `${family}:`; - for (const [key, value] of Object.entries(times)) { - if (typeof value !== "number") continue; - if (value <= now) continue; - if (key !== family && !key.startsWith(prefix)) continue; - if (minReset === null || value < minReset) { - minReset = value; - } - } + }), + ); - return minReset; - }; + if (changed) { + storage.accounts = accountsCopy; + await saveAccounts(storage); + } + return storage; + }; - const formatRateLimitEntry = ( - account: { rateLimitResetTimes?: Record }, - now: number, - family: ModelFamily = "codex", - ): string | null => { - const resetAt = getRateLimitResetTimeForFamily(account, now, family); - if (typeof resetAt !== "number") return null; - const remaining = resetAt - now; - if (remaining <= 0) return null; - return `resets in ${formatWaitTime(remaining)}`; - }; + const getRateLimitResetTimeForFamily = ( + account: { rateLimitResetTimes?: Record }, + now: number, + family: ModelFamily, + ): number | null => { + const times = account.rateLimitResetTimes; + if (!times) return null; + + let minReset: number | null = null; + const prefix = `${family}:`; + for (const [key, value] of Object.entries(times)) { + if (typeof value !== "number") continue; + if (value <= now) continue; + if (key !== family && !key.startsWith(prefix)) continue; + if (minReset === null || value < minReset) { + minReset = value; + } + } - const applyUiRuntimeFromConfig = ( - pluginConfig: ReturnType, - ): UiRuntimeOptions => { - return setUiRuntimeOptions({ - v2Enabled: getCodexTuiV2(pluginConfig), - colorProfile: getCodexTuiColorProfile(pluginConfig), - glyphMode: getCodexTuiGlyphMode(pluginConfig), - }); - }; + return minReset; + }; - const resolveUiRuntime = (): UiRuntimeOptions => { - return applyUiRuntimeFromConfig(loadPluginConfig()); - }; + const formatRateLimitEntry = ( + account: { rateLimitResetTimes?: Record }, + now: number, + family: ModelFamily = "codex", + ): string | null => { + const resetAt = getRateLimitResetTimeForFamily(account, now, family); + if (typeof resetAt !== "number") return null; + const remaining = resetAt - now; + if (remaining <= 0) return null; + return `resets in ${formatWaitTime(remaining)}`; + }; - const getStatusMarker = ( - ui: UiRuntimeOptions, - status: "ok" | "warning" | "error", - ): string => { - if (!ui.v2Enabled) { - if (status === "ok") return "✓"; - if (status === "warning") return "!"; - return "✗"; - } - if (status === "ok") return ui.theme.glyphs.check; + const applyUiRuntimeFromConfig = ( + pluginConfig: ReturnType, + ): UiRuntimeOptions => { + return setUiRuntimeOptions({ + v2Enabled: getCodexTuiV2(pluginConfig), + colorProfile: getCodexTuiColorProfile(pluginConfig), + glyphMode: getCodexTuiGlyphMode(pluginConfig), + }); + }; + + const resolveUiRuntime = (): UiRuntimeOptions => { + return applyUiRuntimeFromConfig(loadPluginConfig()); + }; + + const getStatusMarker = ( + ui: UiRuntimeOptions, + status: "ok" | "warning" | "error", + ): string => { + if (!ui.v2Enabled) { + if (status === "ok") return "✓"; if (status === "warning") return "!"; - return ui.theme.glyphs.cross; - }; + return "✗"; + } + if (status === "ok") return ui.theme.glyphs.check; + if (status === "warning") return "!"; + return ui.theme.glyphs.cross; + }; - const invalidateAccountManagerCache = (): void => { - cachedAccountManager = null; - accountManagerPromise = null; - }; + const invalidateAccountManagerCache = (): void => { + cachedAccountManager = null; + accountManagerPromise = null; + }; - const reloadAccountManagerFromDisk = async ( - authFallback?: OAuthAuthDetails, - ): Promise => { - if (accountReloadInFlight) { - return accountReloadInFlight; - } - accountReloadInFlight = (async () => { - const reloaded = await AccountManager.loadFromDisk(authFallback); - cachedAccountManager = reloaded; - accountManagerPromise = Promise.resolve(reloaded); - return reloaded; - })(); - try { - return await accountReloadInFlight; - } finally { - accountReloadInFlight = null; - } - }; + const reloadAccountManagerFromDisk = async ( + authFallback?: OAuthAuthDetails, + ): Promise => { + if (accountReloadInFlight) { + return accountReloadInFlight; + } + accountReloadInFlight = (async () => { + const reloaded = await AccountManager.loadFromDisk(authFallback); + cachedAccountManager = reloaded; + accountManagerPromise = Promise.resolve(reloaded); + return reloaded; + })(); + try { + return await accountReloadInFlight; + } finally { + accountReloadInFlight = null; + } + }; - const applyAccountStorageScope = (pluginConfig: ReturnType): void => { - const perProjectAccounts = getPerProjectAccounts(pluginConfig); - setStorageBackupEnabled(getStorageBackupEnabled(pluginConfig)); - if (isCodexCliSyncEnabled()) { - if (perProjectAccounts && !perProjectStorageWarningShown) { - perProjectStorageWarningShown = true; - logWarn( - `[${PLUGIN_NAME}] CODEX_AUTH_PER_PROJECT_ACCOUNTS is ignored while Codex CLI sync is enabled. Using global account storage.`, - ); - } - setStoragePath(null); - return; + const applyAccountStorageScope = ( + pluginConfig: ReturnType, + ): void => { + const perProjectAccounts = getPerProjectAccounts(pluginConfig); + setStorageBackupEnabled(getStorageBackupEnabled(pluginConfig)); + if (isCodexCliSyncEnabled()) { + if (perProjectAccounts && !perProjectStorageWarningShown) { + perProjectStorageWarningShown = true; + logWarn( + `[${PLUGIN_NAME}] CODEX_AUTH_PER_PROJECT_ACCOUNTS is ignored while Codex CLI sync is enabled. Using global account storage.`, + ); } + setStoragePath(null); + return; + } - setStoragePath(perProjectAccounts ? process.cwd() : null); - }; + setStoragePath(perProjectAccounts ? process.cwd() : null); + }; - const ensureLiveAccountSync = async ( - pluginConfig: ReturnType, - authFallback?: OAuthAuthDetails, - ): Promise => { - if (!getLiveAccountSync(pluginConfig)) { - if (liveAccountSync) { - liveAccountSync.stop(); - liveAccountSync = null; - liveAccountSyncPath = null; - } - return; + const ensureLiveAccountSync = async ( + pluginConfig: ReturnType, + authFallback?: OAuthAuthDetails, + ): Promise => { + if (!getLiveAccountSync(pluginConfig)) { + if (liveAccountSync) { + liveAccountSync.stop(); + liveAccountSync = null; + liveAccountSyncPath = null; } + return; + } - const targetPath = getStoragePath(); - if (!liveAccountSync) { - liveAccountSync = new LiveAccountSync( - async () => { - await reloadAccountManagerFromDisk(authFallback); - }, - { - debounceMs: getLiveAccountSyncDebounceMs(pluginConfig), - pollIntervalMs: getLiveAccountSyncPollMs(pluginConfig), - }, - ); - registerCleanup(() => { - liveAccountSync?.stop(); - }); - } + const targetPath = getStoragePath(); + if (!liveAccountSync) { + liveAccountSync = new LiveAccountSync( + async () => { + await reloadAccountManagerFromDisk(authFallback); + }, + { + debounceMs: getLiveAccountSyncDebounceMs(pluginConfig), + pollIntervalMs: getLiveAccountSyncPollMs(pluginConfig), + }, + ); + registerCleanup(() => { + liveAccountSync?.stop(); + }); + } if (liveAccountSyncPath !== targetPath) { let switched = false; @@ -932,7 +881,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { if (code !== "EBUSY" && code !== "EPERM") { throw error; } - await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); + await new Promise((resolve) => + setTimeout(resolve, 25 * 2 ** attempt), + ); } } if (!switched) { @@ -943,164 +894,180 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } }; - const ensureRefreshGuardian = ( - pluginConfig: ReturnType, - ): void => { - if (!getProactiveRefreshGuardian(pluginConfig)) { - if (refreshGuardian) { - refreshGuardian.stop(); - refreshGuardian = null; - refreshGuardianConfigKey = null; - } - return; - } - - const intervalMs = getProactiveRefreshIntervalMs(pluginConfig); - const bufferMs = getProactiveRefreshBufferMs(pluginConfig); - const configKey = `${intervalMs}:${bufferMs}`; - if (refreshGuardian && refreshGuardianConfigKey === configKey) return; - + const ensureRefreshGuardian = ( + pluginConfig: ReturnType, + ): void => { + if (!getProactiveRefreshGuardian(pluginConfig)) { if (refreshGuardian) { refreshGuardian.stop(); + refreshGuardian = null; + refreshGuardianConfigKey = null; } - refreshGuardian = new RefreshGuardian( - () => cachedAccountManager, - { intervalMs, bufferMs }, - ); - refreshGuardianConfigKey = configKey; - refreshGuardian.start(); - registerCleanup(() => { - refreshGuardian?.stop(); - }); - }; + return; + } - const ensureSessionAffinity = ( - pluginConfig: ReturnType, - ): void => { - if (!getSessionAffinity(pluginConfig)) { - sessionAffinityStore = null; - sessionAffinityConfigKey = null; - return; - } + const intervalMs = getProactiveRefreshIntervalMs(pluginConfig); + const bufferMs = getProactiveRefreshBufferMs(pluginConfig); + const configKey = `${intervalMs}:${bufferMs}`; + if (refreshGuardian && refreshGuardianConfigKey === configKey) return; - const ttlMs = getSessionAffinityTtlMs(pluginConfig); - const maxEntries = getSessionAffinityMaxEntries(pluginConfig); - const configKey = `${ttlMs}:${maxEntries}`; - if (sessionAffinityStore && sessionAffinityConfigKey === configKey) return; - sessionAffinityStore = new SessionAffinityStore({ ttlMs, maxEntries }); - sessionAffinityConfigKey = configKey; - }; + if (refreshGuardian) { + refreshGuardian.stop(); + } + refreshGuardian = new RefreshGuardian(() => cachedAccountManager, { + intervalMs, + bufferMs, + }); + refreshGuardianConfigKey = configKey; + refreshGuardian.start(); + registerCleanup(() => { + refreshGuardian?.stop(); + }); + }; - const applyPreemptiveQuotaSettings = ( - pluginConfig: ReturnType, - ): void => { - preemptiveQuotaScheduler.configure({ - enabled: getPreemptiveQuotaEnabled(pluginConfig), - remainingPercentThresholdPrimary: getPreemptiveQuotaRemainingPercent5h(pluginConfig), - remainingPercentThresholdSecondary: getPreemptiveQuotaRemainingPercent7d(pluginConfig), - maxDeferralMs: getPreemptiveQuotaMaxDeferralMs(pluginConfig), - }); - }; + const ensureSessionAffinity = ( + pluginConfig: ReturnType, + ): void => { + if (!getSessionAffinity(pluginConfig)) { + sessionAffinityStore = null; + sessionAffinityConfigKey = null; + return; + } - // Event handler for session recovery and account selection - const eventHandler = async (input: { event: { type: string; properties?: unknown } }) => { - try { - const { event } = input; - // Handle TUI account selection events - // Accepts generic selection events with an index property - if ( - event.type === "account.select" || - event.type === "openai.account.select" - ) { - const props = event.properties as { index?: number; accountIndex?: number; provider?: string }; - // Filter by provider if specified - if (props.provider && props.provider !== "openai" && props.provider !== PROVIDER_ID) { - return; - } - - const index = props.index ?? props.accountIndex; - if (typeof index === "number") { - const storage = await loadAccounts(); - if (!storage || index < 0 || index >= storage.accounts.length) { - return; - } - - const now = Date.now(); - const account = storage.accounts[index]; - if (account) { - account.lastUsed = now; - account.lastSwitchReason = "rotation"; - } - storage.activeIndex = index; - storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; - for (const family of MODEL_FAMILIES) { - storage.activeIndexByFamily[family] = index; - } - - await saveAccounts(storage); - if (cachedAccountManager) { - await cachedAccountManager.syncCodexCliActiveSelectionForIndex(index); - } - lastCodexCliActiveSyncIndex = index; + const ttlMs = getSessionAffinityTtlMs(pluginConfig); + const maxEntries = getSessionAffinityMaxEntries(pluginConfig); + const configKey = `${ttlMs}:${maxEntries}`; + if (sessionAffinityStore && sessionAffinityConfigKey === configKey) return; + sessionAffinityStore = new SessionAffinityStore({ ttlMs, maxEntries }); + sessionAffinityConfigKey = configKey; + }; + + const applyPreemptiveQuotaSettings = ( + pluginConfig: ReturnType, + ): void => { + preemptiveQuotaScheduler.configure({ + enabled: getPreemptiveQuotaEnabled(pluginConfig), + remainingPercentThresholdPrimary: + getPreemptiveQuotaRemainingPercent5h(pluginConfig), + remainingPercentThresholdSecondary: + getPreemptiveQuotaRemainingPercent7d(pluginConfig), + maxDeferralMs: getPreemptiveQuotaMaxDeferralMs(pluginConfig), + }); + }; - // Reload manager from disk so we don't overwrite newer rotated - // refresh tokens with stale in-memory state. - if (cachedAccountManager) { - await reloadAccountManagerFromDisk(); + // Event handler for session recovery and account selection + const eventHandler = async (input: { + event: { type: string; properties?: unknown }; + }) => { + try { + const { event } = input; + // Handle TUI account selection events + // Accepts generic selection events with an index property + if ( + event.type === "account.select" || + event.type === "openai.account.select" + ) { + const props = event.properties as { + index?: number; + accountIndex?: number; + provider?: string; + }; + // Filter by provider if specified + if ( + props.provider && + props.provider !== "openai" && + props.provider !== PROVIDER_ID + ) { + return; } - await showToast(`Switched to account ${index + 1}`, "info"); - } - } - } catch (error) { - logDebug(`[${PLUGIN_NAME}] Event handler error: ${error instanceof Error ? error.message : String(error)}`); - } - }; + const index = props.index ?? props.accountIndex; + if (typeof index === "number") { + const storage = await loadAccounts(); + if (!storage || index < 0 || index >= storage.accounts.length) { + return; + } + + const now = Date.now(); + const account = storage.accounts[index]; + if (account) { + account.lastUsed = now; + account.lastSwitchReason = "rotation"; + } + storage.activeIndex = index; + storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; + for (const family of MODEL_FAMILIES) { + storage.activeIndexByFamily[family] = index; + } + + await saveAccounts(storage); + if (cachedAccountManager) { + await cachedAccountManager.syncCodexCliActiveSelectionForIndex( + index, + ); + } + lastCodexCliActiveSyncIndex = index; + + // Reload manager from disk so we don't overwrite newer rotated + // refresh tokens with stale in-memory state. + if (cachedAccountManager) { + await reloadAccountManagerFromDisk(); + } + + await showToast(`Switched to account ${index + 1}`, "info"); + } + } + } catch (error) { + logDebug( + `[${PLUGIN_NAME}] Event handler error: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }; - // Initialize runtime UI settings once on plugin load; auth/tools refresh this dynamically. - resolveUiRuntime(); + // Initialize runtime UI settings once on plugin load; auth/tools refresh this dynamically. + resolveUiRuntime(); - return { - event: eventHandler, - auth: { + return { + event: eventHandler, + auth: { provider: PROVIDER_ID, /** * Loader function that configures OAuth authentication and request handling * * This function: - * 1. Validates OAuth authentication - * 2. Loads multi-account pool from disk (fallback to current auth) - * 3. Loads user configuration from runtime model config - * 4. Fetches Codex system instructions from GitHub (cached) - * 5. Returns SDK configuration with custom fetch implementation + * 1. Validates OAuth authentication + * 2. Loads multi-account pool from disk (fallback to current auth) + * 3. Loads user configuration from runtime model config + * 4. Fetches Codex system instructions from GitHub (cached) + * 5. Returns SDK configuration with custom fetch implementation * * @param getAuth - Function to retrieve current auth state * @param provider - Provider configuration from runtime model config * @returns SDK configuration object or empty object for non-OAuth auth */ - async loader(getAuth: () => Promise, provider: unknown) { - const auth = await getAuth(); - const pluginConfig = loadPluginConfig(); - applyUiRuntimeFromConfig(pluginConfig); - applyAccountStorageScope(pluginConfig); - ensureSessionAffinity(pluginConfig); - ensureRefreshGuardian(pluginConfig); - applyPreemptiveQuotaSettings(pluginConfig); - - // Only handle OAuth auth type, skip API key auth - if (auth.type !== "oauth") { - return {}; - } + async loader(getAuth: () => Promise, provider: unknown) { + const auth = await getAuth(); + const pluginConfig = loadPluginConfig(); + applyUiRuntimeFromConfig(pluginConfig); + applyAccountStorageScope(pluginConfig); + ensureSessionAffinity(pluginConfig); + ensureRefreshGuardian(pluginConfig); + applyPreemptiveQuotaSettings(pluginConfig); + + // Only handle OAuth auth type, skip API key auth + if (auth.type !== "oauth") { + return {}; + } - // Prefer multi-account auth metadata when available, but still handle - // plain OAuth credentials (for legacy runtime versions that inject internal - // Codex auth first and omit the multiAccount marker). - const authWithMulti = auth as typeof auth & { multiAccount?: boolean }; - if (!authWithMulti.multiAccount) { - logDebug( - `[${PLUGIN_NAME}] Auth is missing multiAccount marker; continuing with single-account compatibility mode`, - ); - } + // Prefer multi-account auth metadata when available, but still handle + // plain OAuth credentials (for legacy runtime versions that inject internal + // Codex auth first and omit the multiAccount marker). + const authWithMulti = auth as typeof auth & { multiAccount?: boolean }; + if (!authWithMulti.multiAccount) { + logDebug( + `[${PLUGIN_NAME}] Auth is missing multiAccount marker; continuing with single-account compatibility mode`, + ); + } // Acquire mutex for thread-safe initialization // Use while loop to handle multiple concurrent waiters correctly while (loaderMutex) { @@ -1121,11 +1088,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { reloadAccountManagerFromDisk(auth as OAuthAuthDetails); let accountManager = await managerPromise; cachedAccountManager = accountManager; - const refreshToken = - auth.type === "oauth" ? auth.refresh : ""; + const refreshToken = auth.type === "oauth" ? auth.refresh : ""; const needsPersist = - refreshToken && - !accountManager.hasRefreshToken(refreshToken); + refreshToken && !accountManager.hasRefreshToken(refreshToken); if (needsPersist) { await accountManager.saveToDisk(); } @@ -1136,138 +1101,161 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ); return {}; } - // Extract user configuration (global + per-model options) - const providerConfig = provider as - | { options?: Record; models?: UserConfig["models"] } - | undefined; - const userConfig: UserConfig = { - global: providerConfig?.options || {}, - models: providerConfig?.models || {}, - }; - - // Load plugin configuration and determine CODEX_MODE - // Priority: CODEX_MODE env var > config file > default (true) - const codexMode = getCodexMode(pluginConfig); - const fastSessionEnabled = getFastSession(pluginConfig); - const fastSessionStrategy = getFastSessionStrategy(pluginConfig); - const fastSessionMaxInputItems = getFastSessionMaxInputItems(pluginConfig); - const tokenRefreshSkewMs = getTokenRefreshSkewMs(pluginConfig); - const rateLimitToastDebounceMs = getRateLimitToastDebounceMs(pluginConfig); - const retryAllAccountsRateLimited = getRetryAllAccountsRateLimited(pluginConfig); - const retryAllAccountsMaxWaitMs = getRetryAllAccountsMaxWaitMs(pluginConfig); - const retryAllAccountsMaxRetries = getRetryAllAccountsMaxRetries(pluginConfig); - const unsupportedCodexPolicy = getUnsupportedCodexPolicy(pluginConfig); - const fallbackOnUnsupportedCodexModel = unsupportedCodexPolicy === "fallback"; - const fallbackToGpt52OnUnsupportedGpt53 = - getFallbackToGpt52OnUnsupportedGpt53(pluginConfig); - const unsupportedCodexFallbackChain = - getUnsupportedCodexFallbackChain(pluginConfig); - const toastDurationMs = getToastDurationMs(pluginConfig); - const fetchTimeoutMs = getFetchTimeoutMs(pluginConfig); - const streamStallTimeoutMs = getStreamStallTimeoutMs(pluginConfig); - const networkErrorCooldownMs = getNetworkErrorCooldownMs(pluginConfig); - const serverErrorCooldownMs = getServerErrorCooldownMs(pluginConfig); - const failoverMode = parseFailoverMode(process.env.CODEX_AUTH_FAILOVER_MODE); - const streamFailoverMax = Math.max( - 0, - parseEnvInt(process.env.CODEX_AUTH_STREAM_FAILOVER_MAX) ?? - STREAM_FAILOVER_MAX_BY_MODE[failoverMode], - ); - const streamFailoverSoftTimeoutMs = Math.max( - 1_000, - parseEnvInt(process.env.CODEX_AUTH_STREAM_STALL_SOFT_TIMEOUT_MS) ?? - STREAM_FAILOVER_SOFT_TIMEOUT_BY_MODE[failoverMode], - ); - const streamFailoverHardTimeoutMs = Math.max( - streamFailoverSoftTimeoutMs, - parseEnvInt(process.env.CODEX_AUTH_STREAM_STALL_HARD_TIMEOUT_MS) ?? - streamStallTimeoutMs, - ); - const maxSameAccountRetries = - failoverMode === "conservative" ? 2 : failoverMode === "balanced" ? 1 : 0; - - const sessionRecoveryEnabled = getSessionRecovery(pluginConfig); - const autoResumeEnabled = getAutoResume(pluginConfig); - const emptyResponseMaxRetries = getEmptyResponseMaxRetries(pluginConfig); - const emptyResponseRetryDelayMs = getEmptyResponseRetryDelayMs(pluginConfig); - const pidOffsetEnabled = getPidOffsetEnabled(pluginConfig); - const effectiveUserConfig = fastSessionEnabled - ? applyFastSessionDefaults(userConfig) - : userConfig; - if (fastSessionEnabled) { - logDebug("Fast session mode enabled", { - reasoningEffort: "none/low", - reasoningSummary: "auto", - textVerbosity: "low", - fastSessionStrategy, - fastSessionMaxInputItems, - }); - } + // Extract user configuration (global + per-model options) + const providerConfig = provider as + | { + options?: Record; + models?: UserConfig["models"]; + } + | undefined; + const userConfig: UserConfig = { + global: providerConfig?.options || {}, + models: providerConfig?.models || {}, + }; - const prewarmEnabled = - process.env.CODEX_AUTH_PREWARM !== "0" && - process.env.VITEST !== "true" && - process.env.NODE_ENV !== "test"; - - if (!startupPrewarmTriggered && prewarmEnabled) { - startupPrewarmTriggered = true; - const configuredModels = Object.keys(userConfig.models ?? {}); - prewarmCodexInstructions(configuredModels); - if (codexMode) { - prewarmHostCodexPrompt(); + // Load plugin configuration and determine CODEX_MODE + // Priority: CODEX_MODE env var > config file > default (true) + const codexMode = getCodexMode(pluginConfig); + const fastSessionEnabled = getFastSession(pluginConfig); + const fastSessionStrategy = getFastSessionStrategy(pluginConfig); + const fastSessionMaxInputItems = + getFastSessionMaxInputItems(pluginConfig); + const tokenRefreshSkewMs = getTokenRefreshSkewMs(pluginConfig); + const rateLimitToastDebounceMs = + getRateLimitToastDebounceMs(pluginConfig); + const retryAllAccountsRateLimited = + getRetryAllAccountsRateLimited(pluginConfig); + const retryAllAccountsMaxWaitMs = + getRetryAllAccountsMaxWaitMs(pluginConfig); + const retryAllAccountsMaxRetries = + getRetryAllAccountsMaxRetries(pluginConfig); + const unsupportedCodexPolicy = + getUnsupportedCodexPolicy(pluginConfig); + const fallbackOnUnsupportedCodexModel = + unsupportedCodexPolicy === "fallback"; + const fallbackToGpt52OnUnsupportedGpt53 = + getFallbackToGpt52OnUnsupportedGpt53(pluginConfig); + const unsupportedCodexFallbackChain = + getUnsupportedCodexFallbackChain(pluginConfig); + const toastDurationMs = getToastDurationMs(pluginConfig); + const fetchTimeoutMs = getFetchTimeoutMs(pluginConfig); + const streamStallTimeoutMs = getStreamStallTimeoutMs(pluginConfig); + const networkErrorCooldownMs = + getNetworkErrorCooldownMs(pluginConfig); + const serverErrorCooldownMs = getServerErrorCooldownMs(pluginConfig); + const failoverMode = parseFailoverMode( + process.env.CODEX_AUTH_FAILOVER_MODE, + ); + const streamFailoverMax = Math.max( + 0, + parseEnvInt(process.env.CODEX_AUTH_STREAM_FAILOVER_MAX) ?? + STREAM_FAILOVER_MAX_BY_MODE[failoverMode], + ); + const streamFailoverSoftTimeoutMs = Math.max( + 1_000, + parseEnvInt(process.env.CODEX_AUTH_STREAM_STALL_SOFT_TIMEOUT_MS) ?? + STREAM_FAILOVER_SOFT_TIMEOUT_BY_MODE[failoverMode], + ); + const streamFailoverHardTimeoutMs = Math.max( + streamFailoverSoftTimeoutMs, + parseEnvInt(process.env.CODEX_AUTH_STREAM_STALL_HARD_TIMEOUT_MS) ?? + streamStallTimeoutMs, + ); + const maxSameAccountRetries = + failoverMode === "conservative" + ? 2 + : failoverMode === "balanced" + ? 1 + : 0; + + const sessionRecoveryEnabled = getSessionRecovery(pluginConfig); + const autoResumeEnabled = getAutoResume(pluginConfig); + const emptyResponseMaxRetries = + getEmptyResponseMaxRetries(pluginConfig); + const emptyResponseRetryDelayMs = + getEmptyResponseRetryDelayMs(pluginConfig); + const pidOffsetEnabled = getPidOffsetEnabled(pluginConfig); + const effectiveUserConfig = fastSessionEnabled + ? applyFastSessionDefaults(userConfig) + : userConfig; + if (fastSessionEnabled) { + logDebug("Fast session mode enabled", { + reasoningEffort: "none/low", + reasoningSummary: "auto", + textVerbosity: "low", + fastSessionStrategy, + fastSessionMaxInputItems, + }); } - } - const recoveryHook = sessionRecoveryEnabled - ? createSessionRecoveryHook( - { client, directory: process.cwd() }, - { sessionRecovery: true, autoResume: autoResumeEnabled } - ) - : null; + const prewarmEnabled = + process.env.CODEX_AUTH_PREWARM !== "0" && + process.env.VITEST !== "true" && + process.env.NODE_ENV !== "test"; + + if (!startupPrewarmTriggered && prewarmEnabled) { + startupPrewarmTriggered = true; + const configuredModels = Object.keys(userConfig.models ?? {}); + prewarmCodexInstructions(configuredModels); + if (codexMode) { + prewarmHostCodexPrompt(); + } + } - checkAndNotify(async (message, variant) => { - await showToast(message, variant); - }).catch((err) => { - logDebug(`Update check failed: ${err instanceof Error ? err.message : String(err)}`); - }); + const recoveryHook = sessionRecoveryEnabled + ? createSessionRecoveryHook( + { client, directory: process.cwd() }, + { sessionRecovery: true, autoResume: autoResumeEnabled }, + ) + : null; + checkAndNotify(async (message, variant) => { + await showToast(message, variant); + }).catch((err) => { + logDebug( + `Update check failed: ${err instanceof Error ? err.message : String(err)}`, + ); + }); - // Return SDK configuration - return { - apiKey: DUMMY_API_KEY, - baseURL: CODEX_BASE_URL, - /** - * Custom fetch implementation for Codex API - * - * Handles: - * - Token refresh when expired - * - URL rewriting for Codex backend - * - Request body transformation - * - OAuth header injection - * - SSE to JSON conversion for non-tool requests - * - Error handling and logging - * - * @param input - Request URL or Request object - * @param init - Request options - * @returns Response from Codex API - */ - async fetch( - input: Request | string | URL, - init?: RequestInit, - ): Promise { - try { - if (cachedAccountManager && cachedAccountManager !== accountManager) { - accountManager = cachedAccountManager; - } + // Return SDK configuration + return { + apiKey: DUMMY_API_KEY, + baseURL: CODEX_BASE_URL, + /** + * Custom fetch implementation for Codex API + * + * Handles: + * - Token refresh when expired + * - URL rewriting for Codex backend + * - Request body transformation + * - OAuth header injection + * - SSE to JSON conversion for non-tool requests + * - Error handling and logging + * + * @param input - Request URL or Request object + * @param init - Request options + * @returns Response from Codex API + */ + async fetch( + input: Request | string | URL, + init?: RequestInit, + ): Promise { + try { + if ( + cachedAccountManager && + cachedAccountManager !== accountManager + ) { + accountManager = cachedAccountManager; + } - // Step 1: Extract and rewrite URL for Codex backend - const originalUrl = extractRequestUrl(input); - const url = rewriteUrlForCodex(originalUrl); + // Step 1: Extract and rewrite URL for Codex backend + const originalUrl = extractRequestUrl(input); + const url = rewriteUrlForCodex(originalUrl); - // Step 3: Transform request body with model-specific Codex instructions - // Instructions are fetched per model family (codex-max, codex, gpt-5.1) - // Capture original stream value before transformation - // generateText() sends no stream field, streamText() sends stream=true + // Step 3: Transform request body with model-specific Codex instructions + // Instructions are fetched per model family (codex-max, codex, gpt-5.1) + // Capture original stream value before transformation + // generateText() sends no stream field, streamText() sends stream=true const normalizeRequestInit = async ( requestInput: Request | string | URL, requestInit: RequestInit | undefined, @@ -1306,11 +1294,15 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } if (body instanceof Uint8Array) { - return JSON.parse(new TextDecoder().decode(body)) as Record; + return JSON.parse( + new TextDecoder().decode(body), + ) as Record; } if (body instanceof ArrayBuffer) { - return JSON.parse(new TextDecoder().decode(new Uint8Array(body))) as Record; + return JSON.parse( + new TextDecoder().decode(new Uint8Array(body)), + ) as Record; } if (ArrayBuffer.isView(body)) { @@ -1319,11 +1311,16 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { body.byteOffset, body.byteLength, ); - return JSON.parse(new TextDecoder().decode(view)) as Record; + return JSON.parse( + new TextDecoder().decode(view), + ) as Record; } if (typeof Blob !== "undefined" && body instanceof Blob) { - return JSON.parse(await body.text()) as Record; + return JSON.parse(await body.text()) as Record< + string, + unknown + >; } } catch { logWarn("Failed to parse request body, using empty object"); @@ -1333,10 +1330,14 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }; const baseInit = await normalizeRequestInit(input, init); - const originalBody = await parseRequestBodyFromInit(baseInit?.body); + const originalBody = await parseRequestBodyFromInit( + baseInit?.body, + ); const isStreaming = originalBody.stream === true; const parsedBody = - Object.keys(originalBody).length > 0 ? originalBody : undefined; + Object.keys(originalBody).length > 0 + ? originalBody + : undefined; const transformation = await transformRequestForCodex( baseInit, @@ -1350,1180 +1351,1497 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { fastSessionMaxInputItems, }, ); - let requestInit = transformation?.updatedInit ?? baseInit; - let transformedBody: RequestBody | undefined = transformation?.body; - const promptCacheKey = transformedBody?.prompt_cache_key; - let model = transformedBody?.model; - let modelFamily = model ? getModelFamily(model) : "gpt-5.1"; - let quotaKey = model ? `${modelFamily}:${model}` : modelFamily; - const threadIdCandidate = - (process.env.CODEX_THREAD_ID ?? promptCacheKey ?? "") - .toString() - .trim() || undefined; - const sessionAffinityKey = threadIdCandidate ?? promptCacheKey ?? null; - const effectivePromptCacheKey = - (sessionAffinityKey ?? promptCacheKey ?? "").toString().trim() || undefined; - const preferredSessionAccountIndex = sessionAffinityStore?.getPreferredAccountIndex( - sessionAffinityKey, - ); - sessionAffinityStore?.prune(); - const requestCorrelationId = setCorrelationId( - threadIdCandidate ? `${threadIdCandidate}:${Date.now()}` : undefined, - ); - runtimeMetrics.lastRequestAt = Date.now(); + let requestInit = transformation?.updatedInit ?? baseInit; + let transformedBody: RequestBody | undefined = + transformation?.body; + const promptCacheKey = transformedBody?.prompt_cache_key; + let model = transformedBody?.model; + let modelFamily = model ? getModelFamily(model) : "gpt-5.1"; + let quotaKey = model ? `${modelFamily}:${model}` : modelFamily; + const threadIdCandidate = + (process.env.CODEX_THREAD_ID ?? promptCacheKey ?? "") + .toString() + .trim() || undefined; + const sessionAffinityKey = + threadIdCandidate ?? promptCacheKey ?? null; + const effectivePromptCacheKey = + (sessionAffinityKey ?? promptCacheKey ?? "") + .toString() + .trim() || undefined; + const preferredSessionAccountIndex = + sessionAffinityStore?.getPreferredAccountIndex( + sessionAffinityKey, + ); + sessionAffinityStore?.prune(); + const requestCorrelationId = setCorrelationId( + threadIdCandidate + ? `${threadIdCandidate}:${Date.now()}` + : undefined, + ); + runtimeMetrics.lastRequestAt = Date.now(); - const abortSignal = requestInit?.signal ?? init?.signal ?? null; - const sleep = (ms: number): Promise => - new Promise((resolve, reject) => { - if (abortSignal?.aborted) { - reject(new Error("Aborted")); - return; - } + const abortSignal = requestInit?.signal ?? init?.signal ?? null; + const sleep = (ms: number): Promise => + new Promise((resolve, reject) => { + if (abortSignal?.aborted) { + reject(new Error("Aborted")); + return; + } - const timeout = setTimeout(() => { - cleanup(); - resolve(); - }, ms); + const timeout = setTimeout(() => { + cleanup(); + resolve(); + }, ms); - const onAbort = () => { - cleanup(); - reject(new Error("Aborted")); - }; + const onAbort = () => { + cleanup(); + reject(new Error("Aborted")); + }; - const cleanup = () => { - clearTimeout(timeout); - abortSignal?.removeEventListener("abort", onAbort); - }; + const cleanup = () => { + clearTimeout(timeout); + abortSignal?.removeEventListener("abort", onAbort); + }; - abortSignal?.addEventListener("abort", onAbort, { once: true }); - }); + abortSignal?.addEventListener("abort", onAbort, { + once: true, + }); + }); - const sleepWithCountdown = async ( - totalMs: number, - message: string, - intervalMs: number = 5000, - ): Promise => { - const startTime = Date.now(); - const endTime = startTime + totalMs; - - while (Date.now() < endTime) { - if (abortSignal?.aborted) { - throw new Error("Aborted"); - } - - const remaining = Math.max(0, endTime - Date.now()); - const waitLabel = formatWaitTime(remaining); - await showToast( - `${message} (${waitLabel} remaining)`, - "warning", - { duration: Math.min(intervalMs + 1000, toastDurationMs) }, - ); - - const sleepTime = Math.min(intervalMs, remaining); - if (sleepTime > 0) { - await sleep(sleepTime); - } else { - break; - } - } - }; + const sleepWithCountdown = async ( + totalMs: number, + message: string, + intervalMs: number = 5000, + ): Promise => { + const startTime = Date.now(); + const endTime = startTime + totalMs; - let allRateLimitedRetries = 0; - let emptyResponseRetries = 0; - const attemptedUnsupportedFallbackModels = new Set(); - if (model) { - attemptedUnsupportedFallbackModels.add(model); - } + while (Date.now() < endTime) { + if (abortSignal?.aborted) { + throw new Error("Aborted"); + } - while (true) { - const accountCount = accountManager.getAccountCount(); - const attempted = new Set(); - let restartAccountTraversalWithFallback = false; - let retryNextAccountBeforeFallback = false; - let usedPreferredSessionAccount = false; - const capabilityBoostByAccount: Record = {}; - type AccountSnapshotCandidate = { - index: number; - accountId?: string; - email?: string; - }; - const accountSnapshotSource = accountManager as { - getAccountsSnapshot?: () => AccountSnapshotCandidate[]; - getAccountByIndex?: (index: number) => AccountSnapshotCandidate | null; - }; - const accountSnapshotList = - typeof accountSnapshotSource.getAccountsSnapshot === "function" - ? accountSnapshotSource.getAccountsSnapshot() ?? [] - : []; - if ( - accountSnapshotList.length === 0 && - typeof accountSnapshotSource.getAccountByIndex === "function" + const remaining = Math.max(0, endTime - Date.now()); + const waitLabel = formatWaitTime(remaining); + await showToast( + `${message} (${waitLabel} remaining)`, + "warning", + { + duration: Math.min(intervalMs + 1000, toastDurationMs), + }, + ); + + const sleepTime = Math.min(intervalMs, remaining); + if (sleepTime > 0) { + await sleep(sleepTime); + } else { + break; + } + } + }; + + let allRateLimitedRetries = 0; + let emptyResponseRetries = 0; + const attemptedUnsupportedFallbackModels = new Set(); + if (model) { + attemptedUnsupportedFallbackModels.add(model); + } + + while (true) { + const accountCount = accountManager.getAccountCount(); + const attempted = new Set(); + let restartAccountTraversalWithFallback = false; + let retryNextAccountBeforeFallback = false; + let usedPreferredSessionAccount = false; + const capabilityBoostByAccount: Record = {}; + type AccountSnapshotCandidate = { + index: number; + accountId?: string; + email?: string; + }; + const accountSnapshotSource = accountManager as { + getAccountsSnapshot?: () => AccountSnapshotCandidate[]; + getAccountByIndex?: ( + index: number, + ) => AccountSnapshotCandidate | null; + }; + const accountSnapshotList = + typeof accountSnapshotSource.getAccountsSnapshot === + "function" + ? (accountSnapshotSource.getAccountsSnapshot() ?? []) + : []; + if ( + accountSnapshotList.length === 0 && + typeof accountSnapshotSource.getAccountByIndex === + "function" + ) { + for ( + let accountSnapshotIndex = 0; + accountSnapshotIndex < accountCount; + accountSnapshotIndex += 1 ) { - for ( - let accountSnapshotIndex = 0; - accountSnapshotIndex < accountCount; - accountSnapshotIndex += 1 - ) { - const candidate = accountSnapshotSource.getAccountByIndex( + const candidate = + accountSnapshotSource.getAccountByIndex( accountSnapshotIndex, ); - if (candidate) { - accountSnapshotList.push(candidate); - } + if (candidate) { + accountSnapshotList.push(candidate); } } - for (const candidate of accountSnapshotList) { - const accountKey = resolveEntitlementAccountKey(candidate); - capabilityBoostByAccount[candidate.index] = capabilityPolicyStore.getBoost( + } + for (const candidate of accountSnapshotList) { + const accountKey = resolveEntitlementAccountKey(candidate); + capabilityBoostByAccount[candidate.index] = + capabilityPolicyStore.getBoost( accountKey, model ?? modelFamily, ); - } + } -accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { - let account = null; - if ( - !usedPreferredSessionAccount && - typeof preferredSessionAccountIndex === "number" - ) { - usedPreferredSessionAccount = true; - if ( - accountManager.isAccountAvailableForFamily( - preferredSessionAccountIndex, - modelFamily, - model, - ) - ) { - account = accountManager.getAccountByIndex(preferredSessionAccountIndex); - if (account) { - account.lastUsed = Date.now(); - accountManager.markSwitched(account, "rotation", modelFamily); - } - } else { - sessionAffinityStore?.forgetSession(sessionAffinityKey); - } - } + accountAttemptLoop: while ( + attempted.size < Math.max(1, accountCount) + ) { + let account = null; + if ( + !usedPreferredSessionAccount && + typeof preferredSessionAccountIndex === "number" + ) { + usedPreferredSessionAccount = true; + if ( + accountManager.isAccountAvailableForFamily( + preferredSessionAccountIndex, + modelFamily, + model, + ) + ) { + account = accountManager.getAccountByIndex( + preferredSessionAccountIndex, + ); + if (account) { + account.lastUsed = Date.now(); + accountManager.markSwitched( + account, + "rotation", + modelFamily, + ); + } + } else { + sessionAffinityStore?.forgetSession(sessionAffinityKey); + } + } - if (!account) { - account = accountManager.getCurrentOrNextForFamilyHybrid(modelFamily, model, { - pidOffsetEnabled, - scoreBoostByAccount: capabilityBoostByAccount, - }); - } - if (!account || attempted.has(account.index)) { - break; - } - attempted.add(account.index); - // Log account selection for debugging rotation - logDebug( - `Using account ${account.index + 1}/${accountCount}: ${account.email ?? "unknown"} for ${modelFamily}`, - ); - - let accountAuth = accountManager.toAuthDetails(account) as OAuthAuthDetails; - try { - if (shouldRefreshToken(accountAuth, tokenRefreshSkewMs)) { - accountAuth = (await refreshAndUpdateToken( - accountAuth, - client, - )) as OAuthAuthDetails; - accountManager.updateFromAuth(account, accountAuth); - accountManager.clearAuthFailures(account); - accountManager.saveToDiskDebounced(); - } - } catch (err) { - logDebug(`[${PLUGIN_NAME}] Auth refresh failed for account: ${(err as Error)?.message ?? String(err)}`); - runtimeMetrics.authRefreshFailures++; - runtimeMetrics.failedRequests++; - runtimeMetrics.accountRotations++; - runtimeMetrics.lastError = (err as Error)?.message ?? String(err); - const failures = accountManager.incrementAuthFailures(account); - const accountLabel = formatAccountLabel(account, account.index); - - const authFailurePolicy = evaluateFailurePolicy({ - kind: "auth-refresh", - consecutiveAuthFailures: failures, - }); - sessionAffinityStore?.forgetSession(sessionAffinityKey); - - if (authFailurePolicy.removeAccount) { - const removedIndex = account.index; - sessionAffinityStore?.forgetAccount(removedIndex); - accountManager.removeAccount(account); - sessionAffinityStore?.reindexAfterRemoval(removedIndex); - accountManager.saveToDiskDebounced(); - await showToast( - `Removed ${accountLabel} after ${failures} consecutive auth failures. Run 'codex login' to re-add.`, - "error", - { duration: toastDurationMs * 2 }, - ); - continue; - } + if (!account) { + account = accountManager.getCurrentOrNextForFamilyHybrid( + modelFamily, + model, + { + pidOffsetEnabled, + scoreBoostByAccount: capabilityBoostByAccount, + }, + ); + } + if (!account || attempted.has(account.index)) { + break; + } + attempted.add(account.index); + // Log account selection for debugging rotation + logDebug( + `Using account ${account.index + 1}/${accountCount}: ${account.email ?? "unknown"} for ${modelFamily}`, + ); - if ( - typeof authFailurePolicy.cooldownMs === "number" && - authFailurePolicy.cooldownReason - ) { - accountManager.markAccountCoolingDown( - account, - authFailurePolicy.cooldownMs, - authFailurePolicy.cooldownReason, - ); - } - accountManager.saveToDiskDebounced(); - continue; - } + let accountAuth = accountManager.toAuthDetails( + account, + ) as OAuthAuthDetails; + try { + if (shouldRefreshToken(accountAuth, tokenRefreshSkewMs)) { + accountAuth = (await refreshAndUpdateToken( + accountAuth, + client, + )) as OAuthAuthDetails; + accountManager.updateFromAuth(account, accountAuth); + accountManager.clearAuthFailures(account); + accountManager.saveToDiskDebounced(); + } + } catch (err) { + logDebug( + `[${PLUGIN_NAME}] Auth refresh failed for account: ${(err as Error)?.message ?? String(err)}`, + ); + runtimeMetrics.authRefreshFailures++; + runtimeMetrics.failedRequests++; + runtimeMetrics.accountRotations++; + runtimeMetrics.lastError = + (err as Error)?.message ?? String(err); + const failures = + accountManager.incrementAuthFailures(account); + const accountLabel = formatAccountLabel( + account, + account.index, + ); - const currentWorkspace = accountManager.getCurrentWorkspace(account); - const storedAccountId = currentWorkspace?.id ?? account.accountId; - const storedAccountIdSource = currentWorkspace - ? "manual" - : account.accountIdSource; - const storedEmail = account.email; - const hadAccountId = !!storedAccountId; - const runtimeIdentity = resolveRuntimeRequestIdentity({ - storedAccountId, - source: storedAccountIdSource, - storedEmail, - accessToken: accountAuth.access, - idToken: accountAuth.idToken, - }); - const tokenAccountId = runtimeIdentity.tokenAccountId; - const accountId = runtimeIdentity.accountId; - if (!accountId) { - accountManager.markAccountCoolingDown( - account, - ACCOUNT_LIMITS.AUTH_FAILURE_COOLDOWN_MS, - "auth-failure", - ); - accountManager.saveToDiskDebounced(); - continue; - } - const resolvedEmail = runtimeIdentity.email; - const entitlementAccountKey = resolveEntitlementAccountKey({ - accountId: storedAccountId ?? accountId, - email: resolvedEmail, - refreshToken: account.refreshToken, - index: account.index, + const authFailurePolicy = evaluateFailurePolicy({ + kind: "auth-refresh", + consecutiveAuthFailures: failures, }); - const entitlementBlock = entitlementCache.isBlocked( - entitlementAccountKey, - model ?? modelFamily, - ); - if (entitlementBlock.blocked) { - runtimeMetrics.accountRotations++; - runtimeMetrics.lastError = `Entitlement cached block for account ${account.index + 1}`; - logWarn( - `Skipping account ${account.index + 1} due to cached entitlement block (${formatWaitTime(entitlementBlock.waitMs)} remaining).`, + sessionAffinityStore?.forgetSession(sessionAffinityKey); + + if (authFailurePolicy.removeAccount) { + const removedIndex = account.index; + sessionAffinityStore?.forgetAccount(removedIndex); + accountManager.removeAccount(account); + sessionAffinityStore?.reindexAfterRemoval(removedIndex); + accountManager.saveToDiskDebounced(); + await showToast( + `Removed ${accountLabel} after ${failures} consecutive auth failures. Run 'codex login' to re-add.`, + "error", + { duration: toastDurationMs * 2 }, ); continue; } - account.accountId = accountId; - if (!hadAccountId && tokenAccountId && accountId === tokenAccountId) { - account.accountIdSource = storedAccountIdSource ?? "token"; - } - if (resolvedEmail) { - account.email = resolvedEmail; - } if ( - accountCount > 1 && - accountManager.shouldShowAccountToast( - account.index, - rateLimitToastDebounceMs, - ) + typeof authFailurePolicy.cooldownMs === "number" && + authFailurePolicy.cooldownReason ) { - const accountLabel = formatAccountLabel(account, account.index); - await showToast( - `Using ${accountLabel} (${account.index + 1}/${accountCount})`, - "info", + accountManager.markAccountCoolingDown( + account, + authFailurePolicy.cooldownMs, + authFailurePolicy.cooldownReason, ); - accountManager.markToastShown(account.index); } - - const headers = createCodexHeaders( - requestInit, - accountId, - accountAuth.access, - { - model, - promptCacheKey: effectivePromptCacheKey, - }, - ); - const quotaScheduleKey = `${entitlementAccountKey}:${model ?? modelFamily}`; - const capabilityModelKey = model ?? modelFamily; - const quotaDeferral = preemptiveQuotaScheduler.getDeferral(quotaScheduleKey); - if (quotaDeferral.defer && quotaDeferral.waitMs > 0) { - accountManager.markRateLimitedWithReason( - account, - quotaDeferral.waitMs, - modelFamily, - "quota", - model, - ); - accountManager.recordRateLimit(account, modelFamily, model); - runtimeMetrics.accountRotations++; - runtimeMetrics.lastError = `Preemptive quota deferral for account ${account.index + 1}`; - accountManager.saveToDiskDebounced(); - continue; - } - - // Consume a token before making the request for proactive rate limiting - const tokenConsumed = accountManager.consumeToken(account, modelFamily, model); - if (!tokenConsumed) { - accountManager.recordRateLimit(account, modelFamily, model); - runtimeMetrics.accountRotations++; - runtimeMetrics.lastError = - `Local token bucket depleted for account ${account.index + 1} (${modelFamily}${model ? `:${model}` : ""})`; - logWarn( - `Skipping account ${account.index + 1}: local token bucket depleted for ${modelFamily}${model ? `:${model}` : ""}`, - ); - continue; - } - - let sameAccountRetryCount = 0; - let successAccountForResponse = account; - let successEntitlementAccountKey = entitlementAccountKey; - while (true) { - let response: Response; - const fetchStart = performance.now(); - - // Merge user AbortSignal with timeout (Node 18 compatible - no AbortSignal.any) - const fetchController = new AbortController(); - const requestTimeoutMs = fetchTimeoutMs; - let requestTimedOut = false; - const timeoutReason = new Error("Request timeout"); - const fetchTimeoutId = setTimeout(() => { - requestTimedOut = true; - fetchController.abort(timeoutReason); - }, requestTimeoutMs); - - const onUserAbort = abortSignal - ? () => fetchController.abort(abortSignal.reason ?? new Error("Aborted by user")) - : null; - - if (abortSignal?.aborted) { - clearTimeout(fetchTimeoutId); - fetchController.abort(abortSignal.reason ?? new Error("Aborted by user")); - } else if (abortSignal && onUserAbort) { - abortSignal.addEventListener("abort", onUserAbort, { once: true }); - } - - try { - runtimeMetrics.totalRequests++; - response = await fetch(url, applyProxyCompatibleInit(url, { - ...requestInit, - headers, - signal: fetchController.signal, - })); - } catch (networkError) { - const fetchAbortReason = fetchController.signal.reason; - const isTimeoutAbort = - requestTimedOut || - (fetchAbortReason instanceof Error && - fetchAbortReason.message === timeoutReason.message); - const isUserAbort = Boolean(abortSignal?.aborted) && !isTimeoutAbort; - if (isUserAbort) { - accountManager.refundToken(account, modelFamily, model); - runtimeMetrics.userAborts++; - runtimeMetrics.lastError = "request aborted by user"; - sessionAffinityStore?.forgetSession(sessionAffinityKey); - throw ( - fetchAbortReason instanceof Error - ? fetchAbortReason - : new Error("Aborted by user") - ); - } - const errorMsg = networkError instanceof Error ? networkError.message : String(networkError); - logWarn(`Network error for account ${account.index + 1}: ${errorMsg}`); - runtimeMetrics.failedRequests++; - runtimeMetrics.networkErrors++; - runtimeMetrics.accountRotations++; - runtimeMetrics.lastError = errorMsg; - const policy = evaluateFailurePolicy( - { kind: "network", failoverMode }, - { networkCooldownMs: networkErrorCooldownMs }, - ); - if (policy.refundToken) { - accountManager.refundToken(account, modelFamily, model); - } - if (policy.recordFailure) { - accountManager.recordFailure(account, modelFamily, model); - capabilityPolicyStore.recordFailure( - entitlementAccountKey, - capabilityModelKey, - ); - } - if ( - policy.retrySameAccount && - sameAccountRetryCount < maxSameAccountRetries - ) { - sameAccountRetryCount += 1; - runtimeMetrics.sameAccountRetries += 1; - const retryDelayMs = Math.max( - MIN_BACKOFF_MS, - Math.floor(policy.retryDelayMs ?? 250), - ); - await sleep(addJitter(retryDelayMs, 0.2)); - continue; - } - if ( - typeof policy.cooldownMs === "number" && - policy.cooldownReason - ) { - accountManager.markAccountCoolingDown( - account, - policy.cooldownMs, - policy.cooldownReason, - ); - accountManager.saveToDiskDebounced(); - } - sessionAffinityStore?.forgetSession(sessionAffinityKey); - break; - } finally { - clearTimeout(fetchTimeoutId); - if (abortSignal && onUserAbort) { - abortSignal.removeEventListener("abort", onUserAbort); - } - } - const fetchLatencyMs = Math.round(performance.now() - fetchStart); - - logRequest(LOG_STAGES.RESPONSE, { - status: response.status, - ok: response.ok, - statusText: response.statusText, - latencyMs: fetchLatencyMs, - headers: sanitizeResponseHeadersForLog(response.headers), - }); - const quotaSnapshot = readQuotaSchedulerSnapshot( - response.headers, - response.status, - ); - if (quotaSnapshot) { - preemptiveQuotaScheduler.update(quotaScheduleKey, quotaSnapshot); - } - - if (!response.ok) { - const contextOverflowResult = await handleContextOverflow(response, model); - if (contextOverflowResult.handled) { - return contextOverflowResult.response; - } - - const { response: errorResponse, rateLimit, errorBody } = - await handleErrorResponse(response, { - requestCorrelationId, - threadId: threadIdCandidate, - }); - - const unsupportedModelInfo = getUnsupportedCodexModelInfo(errorBody); - const hasRemainingAccounts = attempted.size < Math.max(1, accountCount); - const blockedModel = - unsupportedModelInfo.unsupportedModel ?? model ?? "requested model"; - const blockedModelNormalized = blockedModel.toLowerCase(); - const shouldForceSparkFallback = - unsupportedModelInfo.isUnsupported && - (blockedModelNormalized === "gpt-5.3-codex-spark" || - blockedModelNormalized.includes("gpt-5.3-codex-spark")); - const allowUnsupportedFallback = - fallbackOnUnsupportedCodexModel || shouldForceSparkFallback; - - // Entitlements can differ by account/workspace, so try remaining - // accounts before degrading the model via fallback. - // Spark entitlement is commonly unavailable on non-Pro/Business workspaces; - // force direct fallback instead of traversing every account/workspace first. - if ( - unsupportedModelInfo.isUnsupported && - hasRemainingAccounts && - !shouldForceSparkFallback - ) { - entitlementCache.markBlocked( - entitlementAccountKey, - blockedModel, - "unsupported-model", - ); - capabilityPolicyStore.recordUnsupported( - entitlementAccountKey, - blockedModel, - ); - accountManager.refundToken(account, modelFamily, model); - accountManager.recordFailure(account, modelFamily, model); - capabilityPolicyStore.recordFailure( - entitlementAccountKey, - capabilityModelKey, - ); - sessionAffinityStore?.forgetSession(sessionAffinityKey); - account.lastSwitchReason = "rotation"; - runtimeMetrics.lastError = `Unsupported model on account ${account.index + 1}: ${blockedModel}`; - logWarn( - `Model ${blockedModel} is unsupported for account ${account.index + 1}. Trying next account/workspace before fallback.`, - { - unsupportedCodexPolicy, - requestedModel: blockedModel, - effectiveModel: blockedModel, - fallbackApplied: false, - fallbackReason: "unsupported-model-entitlement", - }, - ); - retryNextAccountBeforeFallback = true; - break; - } - - const fallbackModel = resolveUnsupportedCodexFallbackModel({ - requestedModel: model, - errorBody, - attemptedModels: attemptedUnsupportedFallbackModels, - fallbackOnUnsupportedCodexModel: allowUnsupportedFallback, - fallbackToGpt52OnUnsupportedGpt53, - customChain: unsupportedCodexFallbackChain, - }); - - if (fallbackModel) { - const previousModel = model ?? "gpt-5-codex"; - const previousModelFamily = modelFamily; - attemptedUnsupportedFallbackModels.add(previousModel); - attemptedUnsupportedFallbackModels.add(fallbackModel); - entitlementCache.markBlocked( - entitlementAccountKey, - previousModel, - "unsupported-model", - ); - capabilityPolicyStore.recordUnsupported( - entitlementAccountKey, - previousModel, - ); - accountManager.refundToken(account, previousModelFamily, previousModel); - - model = fallbackModel; - modelFamily = getModelFamily(model); - quotaKey = `${modelFamily}:${model}`; - - if (transformedBody && typeof transformedBody === "object") { - transformedBody = { ...transformedBody, model }; - } else { - let fallbackBody: Record = { model }; - if (requestInit?.body && typeof requestInit.body === "string") { - try { - const parsed = JSON.parse(requestInit.body) as Record; - fallbackBody = { ...parsed, model }; - } catch { - // Keep minimal fallback body if parsing fails. - } - } - transformedBody = fallbackBody as RequestBody; - } - - requestInit = { - ...(requestInit ?? {}), - body: JSON.stringify(transformedBody), - }; - runtimeMetrics.lastError = `Model fallback: ${previousModel} -> ${model}`; - logWarn( - `Model ${previousModel} is unsupported for this ChatGPT account. Falling back to ${model}.`, - { - unsupportedCodexPolicy, - requestedModel: previousModel, - effectiveModel: model, - fallbackApplied: true, - fallbackReason: "unsupported-model-entitlement", - }, - ); - await showToast( - `Model ${previousModel} is not available for this account. Retrying with ${model}.`, - "warning", - { duration: toastDurationMs }, - ); - restartAccountTraversalWithFallback = true; - break; - } - - if (unsupportedModelInfo.isUnsupported && !allowUnsupportedFallback) { - entitlementCache.markBlocked( - entitlementAccountKey, - blockedModel, - "unsupported-model", - ); - capabilityPolicyStore.recordUnsupported( - entitlementAccountKey, - blockedModel, - ); - runtimeMetrics.lastError = `Unsupported model (strict): ${blockedModel}`; - logWarn( - `Model ${blockedModel} is unsupported for this ChatGPT account. Strict policy blocks automatic fallback.`, - { - unsupportedCodexPolicy, - requestedModel: blockedModel, - effectiveModel: blockedModel, - fallbackApplied: false, - fallbackReason: "unsupported-model-entitlement", - }, - ); - await showToast( - `Model ${blockedModel} is not available for this account. Strict policy blocked automatic fallback.`, - "warning", - { duration: toastDurationMs }, - ); - } - if ( - unsupportedModelInfo.isUnsupported && - allowUnsupportedFallback && - !hasRemainingAccounts && - !fallbackModel - ) { - entitlementCache.markBlocked( - entitlementAccountKey, - blockedModel, - "unsupported-model", - ); - capabilityPolicyStore.recordUnsupported( - entitlementAccountKey, - blockedModel, - ); - } - const workspaceErrorCode = - (errorBody as { error?: { code?: string } } | undefined)?.error?.code ?? ""; - const workspaceErrorMessage = - (errorBody as { error?: { message?: string } } | undefined)?.error?.message ?? ""; - const isDisabledWorkspaceError = - isWorkspaceDisabledError( - errorResponse.status, - workspaceErrorCode, - workspaceErrorMessage, - ); - - // Handle workspace disabled/expired errors by rotating to the next workspace - // within the same account before falling back to another account. - if (isDisabledWorkspaceError) { - runtimeMetrics.failedRequests++; - runtimeMetrics.lastError = `Workspace disabled for account ${account.index + 1}`; - - if (!account.workspaces || account.workspaces.length === 0) { - logWarn( - `Workspace disabled/expired for account ${account.index + 1} without tracked workspaces. Leaving account enabled.`, - { errorCode: workspaceErrorCode }, - ); - if (hasRemainingAccounts) { - continue accountAttemptLoop; - } - return errorResponse; - } else { - const currentWorkspace = accountManager.getCurrentWorkspace(account); - const workspaceName = currentWorkspace?.name ?? currentWorkspace?.id ?? "unknown"; - - logWarn( - `Workspace disabled/expired for account ${account.index + 1} - workspace: ${workspaceName}. Rotating to next workspace.`, - { errorCode: workspaceErrorCode }, - ); - - const disabledWorkspace = currentWorkspace - ? accountManager.disableCurrentWorkspace(account, currentWorkspace.id) - : false; - let nextWorkspace = disabledWorkspace - ? accountManager.rotateToNextWorkspace(account) - : accountManager.getCurrentWorkspace(account); - if (!disabledWorkspace && (!nextWorkspace || nextWorkspace.enabled === false)) { - nextWorkspace = accountManager.rotateToNextWorkspace(account); - } - - if (nextWorkspace) { - accountManager.saveToDiskDebounced(); - - const newWorkspaceName = nextWorkspace.name ?? nextWorkspace.id; - await showToast( - `Workspace ${workspaceName} disabled. Switched to ${newWorkspaceName}.`, - "warning", - { duration: toastDurationMs }, - ); - - logInfo(`Rotated to workspace ${newWorkspaceName} for account ${account.index + 1}`); - - // Allow the same account to be selected again with fresh request state. - attempted.delete(account.index); - continue accountAttemptLoop; - } - - logWarn(`All workspaces disabled for account ${account.index + 1}. Disabling account.`); - - accountManager.setAccountEnabled(account.index, false); - accountManager.saveToDiskDebounced(); - - await showToast( - `All workspaces disabled for account ${account.index + 1}. Switching to another account.`, - "warning", - { duration: toastDurationMs }, - ); - - // Forget session affinity and continue the outer loop so another - // enabled account can service the request. - sessionAffinityStore?.forgetSession(sessionAffinityKey); - continue accountAttemptLoop; - } - } - - if (errorResponse.status === 403 && !unsupportedModelInfo.isUnsupported && !isDisabledWorkspaceError) { - entitlementCache.markBlocked( - entitlementAccountKey, - model ?? modelFamily, - "plan-entitlement", - ); - capabilityPolicyStore.recordFailure( - entitlementAccountKey, - capabilityModelKey, - ); - } - - if (recoveryHook && errorBody && isRecoverableError(errorBody)) { - const errorType = detectErrorType(errorBody); - const toastContent = getRecoveryToastContent(errorType); - await showToast( - `${toastContent.title}: ${toastContent.message}`, - "warning", - { duration: toastDurationMs }, - ); - logDebug(`[${PLUGIN_NAME}] Recoverable error detected: ${errorType}`); - } - - // Handle 5xx server errors by rotating to another account - if (response.status >= 500 && response.status < 600) { - logWarn(`Server error ${response.status} for account ${account.index + 1}. Rotating to next account.`); - runtimeMetrics.failedRequests++; - runtimeMetrics.serverErrors++; - runtimeMetrics.accountRotations++; - runtimeMetrics.lastError = `HTTP ${response.status}`; - const serverRetryAfterMs = parseRetryAfterHintMs(response.headers); - const policy = evaluateFailurePolicy( - { kind: "server", failoverMode, serverRetryAfterMs: serverRetryAfterMs ?? undefined }, - { serverCooldownMs: serverErrorCooldownMs }, - ); - if (policy.refundToken) { - accountManager.refundToken(account, modelFamily, model); - } - if (policy.recordFailure) { - accountManager.recordFailure(account, modelFamily, model); - capabilityPolicyStore.recordFailure( - entitlementAccountKey, - capabilityModelKey, - ); - } - if ( - policy.retrySameAccount && - sameAccountRetryCount < maxSameAccountRetries - ) { - sameAccountRetryCount += 1; - runtimeMetrics.sameAccountRetries += 1; - const retryDelayMs = Math.max( - MIN_BACKOFF_MS, - Math.floor(policy.retryDelayMs ?? 500), - ); - await sleep(addJitter(retryDelayMs, 0.2)); - continue; - } - if ( - typeof policy.cooldownMs === "number" && - policy.cooldownReason - ) { - accountManager.markAccountCoolingDown( - account, - policy.cooldownMs, - policy.cooldownReason, - ); - accountManager.saveToDiskDebounced(); - } - sessionAffinityStore?.forgetSession(sessionAffinityKey); - break; - } - - if (rateLimit) { - runtimeMetrics.rateLimitedResponses++; - const { attempt, delayMs } = getRateLimitBackoff( - account.index, - quotaKey, - rateLimit.retryAfterMs, - ); - preemptiveQuotaScheduler.markRateLimited( - quotaScheduleKey, - delayMs, - ); - const waitLabel = formatWaitTime(delayMs); - - if (delayMs <= RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS) { - if ( - accountManager.shouldShowAccountToast( - account.index, - rateLimitToastDebounceMs, - ) - ) { - await showToast( - `Rate limited. Retrying in ${waitLabel} (attempt ${attempt})...`, - "warning", - { duration: toastDurationMs }, - ); - accountManager.markToastShown(account.index); - } - - await sleep(addJitter(Math.max(MIN_BACKOFF_MS, delayMs), 0.2)); - continue; - } - - accountManager.markRateLimitedWithReason( - account, - delayMs, - modelFamily, - parseRateLimitReason(rateLimit.code), - model, - ); - accountManager.recordRateLimit(account, modelFamily, model); - account.lastSwitchReason = "rate-limit"; - sessionAffinityStore?.forgetSession(sessionAffinityKey); - runtimeMetrics.accountRotations++; - accountManager.saveToDiskDebounced(); - logWarn( - `Rate limited. Rotating account ${account.index + 1} (${account.email ?? "unknown"}).`, - ); - - if ( - accountManager.getAccountCount() > 1 && - accountManager.shouldShowAccountToast( - account.index, - rateLimitToastDebounceMs, - ) - ) { - await showToast( - `Rate limited. Switching accounts (retry in ${waitLabel}).`, - "warning", - { duration: toastDurationMs }, - ); - accountManager.markToastShown(account.index); - } - break; - } - if ( - !rateLimit && - !unsupportedModelInfo.isUnsupported && - errorResponse.status !== 403 - ) { - capabilityPolicyStore.recordFailure( - entitlementAccountKey, - capabilityModelKey, - ); - } - runtimeMetrics.failedRequests++; - runtimeMetrics.lastError = `HTTP ${response.status}`; - return errorResponse; - } - - resetRateLimitBackoff(account.index, quotaKey); - runtimeMetrics.cumulativeLatencyMs += fetchLatencyMs; - let responseForSuccess = response; - if (isStreaming) { - const streamFallbackCandidateOrder = [ - account.index, - ...accountManager - .getAccountsSnapshot() - .map((candidate) => candidate.index) - .filter((index) => index !== account.index), - ]; - responseForSuccess = withStreamingFailover( - response, - async (failoverAttempt, emittedBytes) => { - if (abortSignal?.aborted) { - return null; - } - runtimeMetrics.streamFailoverAttempts += 1; - - for (const candidateIndex of streamFallbackCandidateOrder) { - if (abortSignal?.aborted) { - return null; - } - if ( - !accountManager.isAccountAvailableForFamily( - candidateIndex, - modelFamily, - model, - ) - ) { + accountManager.saveToDiskDebounced(); continue; } - const fallbackAccount = accountManager.getAccountByIndex(candidateIndex); - if (!fallbackAccount) continue; - - let fallbackAuth = accountManager.toAuthDetails(fallbackAccount) as OAuthAuthDetails; - try { - if (shouldRefreshToken(fallbackAuth, tokenRefreshSkewMs)) { - fallbackAuth = (await refreshAndUpdateToken( - fallbackAuth, - client, - )) as OAuthAuthDetails; - accountManager.updateFromAuth(fallbackAccount, fallbackAuth); - accountManager.clearAuthFailures(fallbackAccount); - accountManager.saveToDiskDebounced(); - } - } catch (refreshError) { - logWarn( - `Stream failover refresh failed for account ${fallbackAccount.index + 1}.`, - { - error: - refreshError instanceof Error - ? refreshError.message - : String(refreshError), - }, - ); - continue; - } - - const fallbackStoredAccountId = fallbackAccount.accountId; - const fallbackStoredAccountIdSource = fallbackAccount.accountIdSource; - const fallbackStoredEmail = fallbackAccount.email; - const hadFallbackAccountId = !!fallbackStoredAccountId; - const fallbackRuntimeIdentity = resolveRuntimeRequestIdentity({ - storedAccountId: fallbackStoredAccountId, - source: fallbackStoredAccountIdSource, - storedEmail: fallbackStoredEmail, - accessToken: fallbackAuth.access, - idToken: fallbackAuth.idToken, + const currentWorkspace = + accountManager.getCurrentWorkspace(account); + const storedAccountId = + currentWorkspace?.id ?? account.accountId; + const storedAccountIdSource = currentWorkspace + ? "manual" + : account.accountIdSource; + const storedEmail = account.email; + const hadAccountId = !!storedAccountId; + const runtimeIdentity = resolveRuntimeRequestIdentity({ + storedAccountId, + source: storedAccountIdSource, + storedEmail, + accessToken: accountAuth.access, + idToken: accountAuth.idToken, }); - const fallbackTokenAccountId = fallbackRuntimeIdentity.tokenAccountId; - const fallbackAccountId = fallbackRuntimeIdentity.accountId; - if (!fallbackAccountId) { + const tokenAccountId = runtimeIdentity.tokenAccountId; + const accountId = runtimeIdentity.accountId; + if (!accountId) { + accountManager.markAccountCoolingDown( + account, + ACCOUNT_LIMITS.AUTH_FAILURE_COOLDOWN_MS, + "auth-failure", + ); + accountManager.saveToDiskDebounced(); continue; } - const fallbackResolvedEmail = fallbackRuntimeIdentity.email; - const fallbackEntitlementAccountKey = resolveEntitlementAccountKey({ - accountId: fallbackStoredAccountId ?? fallbackAccountId, - email: fallbackResolvedEmail, - refreshToken: fallbackAccount.refreshToken, - index: fallbackAccount.index, + const resolvedEmail = runtimeIdentity.email; + const entitlementAccountKey = resolveEntitlementAccountKey({ + accountId: storedAccountId ?? accountId, + email: resolvedEmail, + refreshToken: account.refreshToken, + index: account.index, }); - const fallbackEntitlementBlock = entitlementCache.isBlocked( - fallbackEntitlementAccountKey, + const entitlementBlock = entitlementCache.isBlocked( + entitlementAccountKey, model ?? modelFamily, ); - if (fallbackEntitlementBlock.blocked) { + if (entitlementBlock.blocked) { runtimeMetrics.accountRotations++; - runtimeMetrics.lastError = - `Entitlement cached block for account ${fallbackAccount.index + 1}`; + runtimeMetrics.lastError = `Entitlement cached block for account ${account.index + 1}`; logWarn( - `Skipping account ${fallbackAccount.index + 1} due to cached entitlement block (${formatWaitTime(fallbackEntitlementBlock.waitMs)} remaining).`, + `Skipping account ${account.index + 1} due to cached entitlement block (${formatWaitTime(entitlementBlock.waitMs)} remaining).`, ); continue; } - - if (!accountManager.consumeToken(fallbackAccount, modelFamily, model)) { - continue; - } - fallbackAccount.accountId = fallbackAccountId; + account.accountId = accountId; if ( - !hadFallbackAccountId && - fallbackTokenAccountId && - fallbackAccountId === fallbackTokenAccountId + !hadAccountId && + tokenAccountId && + accountId === tokenAccountId ) { - fallbackAccount.accountIdSource = - fallbackStoredAccountIdSource ?? "token"; + account.accountIdSource = + storedAccountIdSource ?? "token"; + } + if (resolvedEmail) { + account.email = resolvedEmail; } - if (fallbackResolvedEmail) { - fallbackAccount.email = fallbackResolvedEmail; + + if ( + accountCount > 1 && + accountManager.shouldShowAccountToast( + account.index, + rateLimitToastDebounceMs, + ) + ) { + const accountLabel = formatAccountLabel( + account, + account.index, + ); + await showToast( + `Using ${accountLabel} (${account.index + 1}/${accountCount})`, + "info", + ); + accountManager.markToastShown(account.index); } - const fallbackHeaders = createCodexHeaders( + const headers = createCodexHeaders( requestInit, - fallbackAccountId, - fallbackAuth.access, + accountId, + accountAuth.access, { model, promptCacheKey: effectivePromptCacheKey, }, ); + const quotaScheduleKey = `${entitlementAccountKey}:${model ?? modelFamily}`; + const capabilityModelKey = model ?? modelFamily; + const quotaDeferral = + preemptiveQuotaScheduler.getDeferral(quotaScheduleKey); + if (quotaDeferral.defer && quotaDeferral.waitMs > 0) { + accountManager.markRateLimitedWithReason( + account, + quotaDeferral.waitMs, + modelFamily, + "quota", + model, + ); + accountManager.recordRateLimit( + account, + modelFamily, + model, + ); + runtimeMetrics.accountRotations++; + runtimeMetrics.lastError = `Preemptive quota deferral for account ${account.index + 1}`; + accountManager.saveToDiskDebounced(); + continue; + } - const fallbackController = new AbortController(); - const fallbackTimeoutId = setTimeout( - () => fallbackController.abort(new Error("Request timeout")), - fetchTimeoutMs, + // Consume a token before making the request for proactive rate limiting + const tokenConsumed = accountManager.consumeToken( + account, + modelFamily, + model, ); - const onFallbackAbort = abortSignal - ? () => - fallbackController.abort( - abortSignal.reason ?? new Error("Aborted by user"), - ) - : null; - if (abortSignal && onFallbackAbort) { - abortSignal.addEventListener("abort", onFallbackAbort, { - once: true, - }); + if (!tokenConsumed) { + accountManager.recordRateLimit( + account, + modelFamily, + model, + ); + runtimeMetrics.accountRotations++; + runtimeMetrics.lastError = `Local token bucket depleted for account ${account.index + 1} (${modelFamily}${model ? `:${model}` : ""})`; + logWarn( + `Skipping account ${account.index + 1}: local token bucket depleted for ${modelFamily}${model ? `:${model}` : ""}`, + ); + continue; } - try { - runtimeMetrics.totalRequests++; - const fallbackResponse = await fetch(url, applyProxyCompatibleInit(url, { - ...requestInit, - headers: fallbackHeaders, - signal: fallbackController.signal, - })); - const fallbackSnapshot = readQuotaSchedulerSnapshot( - fallbackResponse.headers, - fallbackResponse.status, + let sameAccountRetryCount = 0; + let successAccountForResponse = account; + let successEntitlementAccountKey = entitlementAccountKey; + while (true) { + let response: Response; + const fetchStart = performance.now(); + + // Merge user AbortSignal with timeout (Node 18 compatible - no AbortSignal.any) + const fetchController = new AbortController(); + const requestTimeoutMs = fetchTimeoutMs; + let requestTimedOut = false; + const timeoutReason = new Error("Request timeout"); + const fetchTimeoutId = setTimeout(() => { + requestTimedOut = true; + fetchController.abort(timeoutReason); + }, requestTimeoutMs); + + const onUserAbort = abortSignal + ? () => + fetchController.abort( + abortSignal.reason ?? + new Error("Aborted by user"), + ) + : null; + + if (abortSignal?.aborted) { + clearTimeout(fetchTimeoutId); + fetchController.abort( + abortSignal.reason ?? new Error("Aborted by user"), + ); + } else if (abortSignal && onUserAbort) { + abortSignal.addEventListener("abort", onUserAbort, { + once: true, + }); + } + + try { + runtimeMetrics.totalRequests++; + response = await fetch( + url, + applyProxyCompatibleInit(url, { + ...requestInit, + headers, + signal: fetchController.signal, + }), + ); + } catch (networkError) { + const fetchAbortReason = fetchController.signal.reason; + const isTimeoutAbort = + requestTimedOut || + (fetchAbortReason instanceof Error && + fetchAbortReason.message === timeoutReason.message); + const isUserAbort = + Boolean(abortSignal?.aborted) && !isTimeoutAbort; + if (isUserAbort) { + accountManager.refundToken( + account, + modelFamily, + model, + ); + runtimeMetrics.userAborts++; + runtimeMetrics.lastError = "request aborted by user"; + sessionAffinityStore?.forgetSession( + sessionAffinityKey, + ); + throw fetchAbortReason instanceof Error + ? fetchAbortReason + : new Error("Aborted by user"); + } + const errorMsg = + networkError instanceof Error + ? networkError.message + : String(networkError); + logWarn( + `Network error for account ${account.index + 1}: ${errorMsg}`, + ); + runtimeMetrics.failedRequests++; + runtimeMetrics.networkErrors++; + runtimeMetrics.accountRotations++; + runtimeMetrics.lastError = errorMsg; + const policy = evaluateFailurePolicy( + { kind: "network", failoverMode }, + { networkCooldownMs: networkErrorCooldownMs }, + ); + if (policy.refundToken) { + accountManager.refundToken( + account, + modelFamily, + model, + ); + } + if (policy.recordFailure) { + accountManager.recordFailure( + account, + modelFamily, + model, + ); + capabilityPolicyStore.recordFailure( + entitlementAccountKey, + capabilityModelKey, + ); + } + if ( + policy.retrySameAccount && + sameAccountRetryCount < maxSameAccountRetries + ) { + sameAccountRetryCount += 1; + runtimeMetrics.sameAccountRetries += 1; + const retryDelayMs = Math.max( + MIN_BACKOFF_MS, + Math.floor(policy.retryDelayMs ?? 250), + ); + await sleep(addJitter(retryDelayMs, 0.2)); + continue; + } + if ( + typeof policy.cooldownMs === "number" && + policy.cooldownReason + ) { + accountManager.markAccountCoolingDown( + account, + policy.cooldownMs, + policy.cooldownReason, + ); + accountManager.saveToDiskDebounced(); + } + sessionAffinityStore?.forgetSession(sessionAffinityKey); + break; + } finally { + clearTimeout(fetchTimeoutId); + if (abortSignal && onUserAbort) { + abortSignal.removeEventListener("abort", onUserAbort); + } + } + const fetchLatencyMs = Math.round( + performance.now() - fetchStart, + ); + + logRequest(LOG_STAGES.RESPONSE, { + status: response.status, + ok: response.ok, + statusText: response.statusText, + latencyMs: fetchLatencyMs, + headers: sanitizeResponseHeadersForLog( + response.headers, + ), + }); + const quotaSnapshot = readQuotaSchedulerSnapshot( + response.headers, + response.status, ); - if (fallbackSnapshot) { + if (quotaSnapshot) { preemptiveQuotaScheduler.update( - `${fallbackEntitlementAccountKey}:${model ?? modelFamily}`, - fallbackSnapshot, + quotaScheduleKey, + quotaSnapshot, ); } - if (!fallbackResponse.ok) { - try { - await fallbackResponse.body?.cancel(); - } catch { - // Best effort cleanup before trying next fallback account. + + if (!response.ok) { + const contextOverflowResult = + await handleContextOverflow(response, model); + if (contextOverflowResult.handled) { + return contextOverflowResult.response; } - if (fallbackResponse.status === 429) { - const retryAfterMs = - parseRetryAfterHintMs(fallbackResponse.headers) ?? 60_000; - accountManager.markRateLimitedWithReason( - fallbackAccount, - retryAfterMs, + + const { + response: errorResponse, + rateLimit, + errorBody, + } = await handleErrorResponse(response, { + requestCorrelationId, + threadId: threadIdCandidate, + }); + + const unsupportedModelInfo = + getUnsupportedCodexModelInfo(errorBody); + const hasRemainingAccounts = + attempted.size < Math.max(1, accountCount); + const blockedModel = + unsupportedModelInfo.unsupportedModel ?? + model ?? + "requested model"; + const blockedModelNormalized = + blockedModel.toLowerCase(); + const shouldForceSparkFallback = + unsupportedModelInfo.isUnsupported && + (blockedModelNormalized === "gpt-5.3-codex-spark" || + blockedModelNormalized.includes( + "gpt-5.3-codex-spark", + )); + const allowUnsupportedFallback = + fallbackOnUnsupportedCodexModel || + shouldForceSparkFallback; + + // Entitlements can differ by account/workspace, so try remaining + // accounts before degrading the model via fallback. + // Spark entitlement is commonly unavailable on non-Pro/Business workspaces; + // force direct fallback instead of traversing every account/workspace first. + if ( + unsupportedModelInfo.isUnsupported && + hasRemainingAccounts && + !shouldForceSparkFallback + ) { + entitlementCache.markBlocked( + entitlementAccountKey, + blockedModel, + "unsupported-model", + ); + capabilityPolicyStore.recordUnsupported( + entitlementAccountKey, + blockedModel, + ); + accountManager.refundToken( + account, modelFamily, - "quota", model, ); - accountManager.recordRateLimit(fallbackAccount, modelFamily, model); - } else { - accountManager.recordFailure(fallbackAccount, modelFamily, model); + accountManager.recordFailure( + account, + modelFamily, + model, + ); + capabilityPolicyStore.recordFailure( + entitlementAccountKey, + capabilityModelKey, + ); + sessionAffinityStore?.forgetSession( + sessionAffinityKey, + ); + account.lastSwitchReason = "rotation"; + runtimeMetrics.lastError = `Unsupported model on account ${account.index + 1}: ${blockedModel}`; + logWarn( + `Model ${blockedModel} is unsupported for account ${account.index + 1}. Trying next account/workspace before fallback.`, + { + unsupportedCodexPolicy, + requestedModel: blockedModel, + effectiveModel: blockedModel, + fallbackApplied: false, + fallbackReason: "unsupported-model-entitlement", + }, + ); + retryNextAccountBeforeFallback = true; + break; } - capabilityPolicyStore.recordFailure( - fallbackEntitlementAccountKey, - capabilityModelKey, - ); - continue; + + const fallbackModel = + resolveUnsupportedCodexFallbackModel({ + requestedModel: model, + errorBody, + attemptedModels: attemptedUnsupportedFallbackModels, + fallbackOnUnsupportedCodexModel: + allowUnsupportedFallback, + fallbackToGpt52OnUnsupportedGpt53, + customChain: unsupportedCodexFallbackChain, + }); + + if (fallbackModel) { + const previousModel = model ?? "gpt-5-codex"; + const previousModelFamily = modelFamily; + attemptedUnsupportedFallbackModels.add(previousModel); + attemptedUnsupportedFallbackModels.add(fallbackModel); + entitlementCache.markBlocked( + entitlementAccountKey, + previousModel, + "unsupported-model", + ); + capabilityPolicyStore.recordUnsupported( + entitlementAccountKey, + previousModel, + ); + accountManager.refundToken( + account, + previousModelFamily, + previousModel, + ); + + model = fallbackModel; + modelFamily = getModelFamily(model); + quotaKey = `${modelFamily}:${model}`; + + if ( + transformedBody && + typeof transformedBody === "object" + ) { + transformedBody = { ...transformedBody, model }; + } else { + let fallbackBody: Record = { + model, + }; + if ( + requestInit?.body && + typeof requestInit.body === "string" + ) { + try { + const parsed = JSON.parse( + requestInit.body, + ) as Record; + fallbackBody = { ...parsed, model }; + } catch { + // Keep minimal fallback body if parsing fails. + } + } + transformedBody = fallbackBody as RequestBody; + } + + requestInit = { + ...(requestInit ?? {}), + body: JSON.stringify(transformedBody), + }; + runtimeMetrics.lastError = `Model fallback: ${previousModel} -> ${model}`; + logWarn( + `Model ${previousModel} is unsupported for this ChatGPT account. Falling back to ${model}.`, + { + unsupportedCodexPolicy, + requestedModel: previousModel, + effectiveModel: model, + fallbackApplied: true, + fallbackReason: "unsupported-model-entitlement", + }, + ); + await showToast( + `Model ${previousModel} is not available for this account. Retrying with ${model}.`, + "warning", + { duration: toastDurationMs }, + ); + restartAccountTraversalWithFallback = true; + break; + } + + if ( + unsupportedModelInfo.isUnsupported && + !allowUnsupportedFallback + ) { + entitlementCache.markBlocked( + entitlementAccountKey, + blockedModel, + "unsupported-model", + ); + capabilityPolicyStore.recordUnsupported( + entitlementAccountKey, + blockedModel, + ); + runtimeMetrics.lastError = `Unsupported model (strict): ${blockedModel}`; + logWarn( + `Model ${blockedModel} is unsupported for this ChatGPT account. Strict policy blocks automatic fallback.`, + { + unsupportedCodexPolicy, + requestedModel: blockedModel, + effectiveModel: blockedModel, + fallbackApplied: false, + fallbackReason: "unsupported-model-entitlement", + }, + ); + await showToast( + `Model ${blockedModel} is not available for this account. Strict policy blocked automatic fallback.`, + "warning", + { duration: toastDurationMs }, + ); + } + if ( + unsupportedModelInfo.isUnsupported && + allowUnsupportedFallback && + !hasRemainingAccounts && + !fallbackModel + ) { + entitlementCache.markBlocked( + entitlementAccountKey, + blockedModel, + "unsupported-model", + ); + capabilityPolicyStore.recordUnsupported( + entitlementAccountKey, + blockedModel, + ); + } + const workspaceErrorCode = + ( + errorBody as + | { error?: { code?: string } } + | undefined + )?.error?.code ?? ""; + const workspaceErrorMessage = + ( + errorBody as + | { error?: { message?: string } } + | undefined + )?.error?.message ?? ""; + const isDisabledWorkspaceError = + isWorkspaceDisabledError( + errorResponse.status, + workspaceErrorCode, + workspaceErrorMessage, + ); + + // Handle workspace disabled/expired errors by rotating to the next workspace + // within the same account before falling back to another account. + if (isDisabledWorkspaceError) { + runtimeMetrics.failedRequests++; + runtimeMetrics.lastError = `Workspace disabled for account ${account.index + 1}`; + + if ( + !account.workspaces || + account.workspaces.length === 0 + ) { + logWarn( + `Workspace disabled/expired for account ${account.index + 1} without tracked workspaces. Leaving account enabled.`, + { errorCode: workspaceErrorCode }, + ); + if (hasRemainingAccounts) { + continue accountAttemptLoop; + } + return errorResponse; + } else { + const currentWorkspace = + accountManager.getCurrentWorkspace(account); + const workspaceName = + currentWorkspace?.name ?? + currentWorkspace?.id ?? + "unknown"; + + logWarn( + `Workspace disabled/expired for account ${account.index + 1} - workspace: ${workspaceName}. Rotating to next workspace.`, + { errorCode: workspaceErrorCode }, + ); + + const disabledWorkspace = currentWorkspace + ? accountManager.disableCurrentWorkspace( + account, + currentWorkspace.id, + ) + : false; + let nextWorkspace = disabledWorkspace + ? accountManager.rotateToNextWorkspace(account) + : accountManager.getCurrentWorkspace(account); + if ( + !disabledWorkspace && + (!nextWorkspace || + nextWorkspace.enabled === false) + ) { + nextWorkspace = + accountManager.rotateToNextWorkspace(account); + } + + if (nextWorkspace) { + accountManager.saveToDiskDebounced(); + + const newWorkspaceName = + nextWorkspace.name ?? nextWorkspace.id; + await showToast( + `Workspace ${workspaceName} disabled. Switched to ${newWorkspaceName}.`, + "warning", + { duration: toastDurationMs }, + ); + + logInfo( + `Rotated to workspace ${newWorkspaceName} for account ${account.index + 1}`, + ); + + // Allow the same account to be selected again with fresh request state. + attempted.delete(account.index); + continue accountAttemptLoop; + } + + logWarn( + `All workspaces disabled for account ${account.index + 1}. Disabling account.`, + ); + + accountManager.setAccountEnabled( + account.index, + false, + ); + accountManager.saveToDiskDebounced(); + + await showToast( + `All workspaces disabled for account ${account.index + 1}. Switching to another account.`, + "warning", + { duration: toastDurationMs }, + ); + + // Forget session affinity and continue the outer loop so another + // enabled account can service the request. + sessionAffinityStore?.forgetSession( + sessionAffinityKey, + ); + continue accountAttemptLoop; + } + } + + if ( + errorResponse.status === 403 && + !unsupportedModelInfo.isUnsupported && + !isDisabledWorkspaceError + ) { + entitlementCache.markBlocked( + entitlementAccountKey, + model ?? modelFamily, + "plan-entitlement", + ); + capabilityPolicyStore.recordFailure( + entitlementAccountKey, + capabilityModelKey, + ); + } + + if ( + recoveryHook && + errorBody && + isRecoverableError(errorBody) + ) { + const errorType = detectErrorType(errorBody); + const toastContent = + getRecoveryToastContent(errorType); + await showToast( + `${toastContent.title}: ${toastContent.message}`, + "warning", + { duration: toastDurationMs }, + ); + logDebug( + `[${PLUGIN_NAME}] Recoverable error detected: ${errorType}`, + ); + } + + // Handle 5xx server errors by rotating to another account + if (response.status >= 500 && response.status < 600) { + logWarn( + `Server error ${response.status} for account ${account.index + 1}. Rotating to next account.`, + ); + runtimeMetrics.failedRequests++; + runtimeMetrics.serverErrors++; + runtimeMetrics.accountRotations++; + runtimeMetrics.lastError = `HTTP ${response.status}`; + const serverRetryAfterMs = parseRetryAfterHintMs( + response.headers, + ); + const policy = evaluateFailurePolicy( + { + kind: "server", + failoverMode, + serverRetryAfterMs: + serverRetryAfterMs ?? undefined, + }, + { serverCooldownMs: serverErrorCooldownMs }, + ); + if (policy.refundToken) { + accountManager.refundToken( + account, + modelFamily, + model, + ); + } + if (policy.recordFailure) { + accountManager.recordFailure( + account, + modelFamily, + model, + ); + capabilityPolicyStore.recordFailure( + entitlementAccountKey, + capabilityModelKey, + ); + } + if ( + policy.retrySameAccount && + sameAccountRetryCount < maxSameAccountRetries + ) { + sameAccountRetryCount += 1; + runtimeMetrics.sameAccountRetries += 1; + const retryDelayMs = Math.max( + MIN_BACKOFF_MS, + Math.floor(policy.retryDelayMs ?? 500), + ); + await sleep(addJitter(retryDelayMs, 0.2)); + continue; + } + if ( + typeof policy.cooldownMs === "number" && + policy.cooldownReason + ) { + accountManager.markAccountCoolingDown( + account, + policy.cooldownMs, + policy.cooldownReason, + ); + accountManager.saveToDiskDebounced(); + } + sessionAffinityStore?.forgetSession( + sessionAffinityKey, + ); + break; + } + + if (rateLimit) { + runtimeMetrics.rateLimitedResponses++; + const { attempt, delayMs } = getRateLimitBackoff( + account.index, + quotaKey, + rateLimit.retryAfterMs, + ); + preemptiveQuotaScheduler.markRateLimited( + quotaScheduleKey, + delayMs, + ); + const waitLabel = formatWaitTime(delayMs); + + if (delayMs <= RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS) { + if ( + accountManager.shouldShowAccountToast( + account.index, + rateLimitToastDebounceMs, + ) + ) { + await showToast( + `Rate limited. Retrying in ${waitLabel} (attempt ${attempt})...`, + "warning", + { duration: toastDurationMs }, + ); + accountManager.markToastShown(account.index); + } + + await sleep( + addJitter(Math.max(MIN_BACKOFF_MS, delayMs), 0.2), + ); + continue; + } + + accountManager.markRateLimitedWithReason( + account, + delayMs, + modelFamily, + parseRateLimitReason(rateLimit.code), + model, + ); + accountManager.recordRateLimit( + account, + modelFamily, + model, + ); + account.lastSwitchReason = "rate-limit"; + sessionAffinityStore?.forgetSession( + sessionAffinityKey, + ); + runtimeMetrics.accountRotations++; + accountManager.saveToDiskDebounced(); + logWarn( + `Rate limited. Rotating account ${account.index + 1} (${account.email ?? "unknown"}).`, + ); + + if ( + accountManager.getAccountCount() > 1 && + accountManager.shouldShowAccountToast( + account.index, + rateLimitToastDebounceMs, + ) + ) { + await showToast( + `Rate limited. Switching accounts (retry in ${waitLabel}).`, + "warning", + { duration: toastDurationMs }, + ); + accountManager.markToastShown(account.index); + } + break; + } + if ( + !rateLimit && + !unsupportedModelInfo.isUnsupported && + errorResponse.status !== 403 + ) { + capabilityPolicyStore.recordFailure( + entitlementAccountKey, + capabilityModelKey, + ); + } + runtimeMetrics.failedRequests++; + runtimeMetrics.lastError = `HTTP ${response.status}`; + return errorResponse; } - successAccountForResponse = fallbackAccount; - successEntitlementAccountKey = fallbackEntitlementAccountKey; - runtimeMetrics.streamFailoverRecoveries += 1; - if (fallbackAccount.index !== account.index) { - runtimeMetrics.streamFailoverCrossAccountRecoveries += 1; - runtimeMetrics.accountRotations += 1; - sessionAffinityStore?.remember( - sessionAffinityKey, - fallbackAccount.index, + resetRateLimitBackoff(account.index, quotaKey); + runtimeMetrics.cumulativeLatencyMs += fetchLatencyMs; + let responseForSuccess = response; + if (isStreaming) { + const streamFallbackCandidateOrder = [ + account.index, + ...accountManager + .getAccountsSnapshot() + .map((candidate) => candidate.index) + .filter((index) => index !== account.index), + ]; + responseForSuccess = withStreamingFailover( + response, + async (failoverAttempt, emittedBytes) => { + if (abortSignal?.aborted) { + return null; + } + runtimeMetrics.streamFailoverAttempts += 1; + + for (const candidateIndex of streamFallbackCandidateOrder) { + if (abortSignal?.aborted) { + return null; + } + if ( + !accountManager.isAccountAvailableForFamily( + candidateIndex, + modelFamily, + model, + ) + ) { + continue; + } + + const fallbackAccount = + accountManager.getAccountByIndex( + candidateIndex, + ); + if (!fallbackAccount) continue; + + let fallbackAuth = accountManager.toAuthDetails( + fallbackAccount, + ) as OAuthAuthDetails; + try { + if ( + shouldRefreshToken( + fallbackAuth, + tokenRefreshSkewMs, + ) + ) { + fallbackAuth = (await refreshAndUpdateToken( + fallbackAuth, + client, + )) as OAuthAuthDetails; + accountManager.updateFromAuth( + fallbackAccount, + fallbackAuth, + ); + accountManager.clearAuthFailures( + fallbackAccount, + ); + accountManager.saveToDiskDebounced(); + } + } catch (refreshError) { + logWarn( + `Stream failover refresh failed for account ${fallbackAccount.index + 1}.`, + { + error: + refreshError instanceof Error + ? refreshError.message + : String(refreshError), + }, + ); + continue; + } + + const fallbackStoredAccountId = + fallbackAccount.accountId; + const fallbackStoredAccountIdSource = + fallbackAccount.accountIdSource; + const fallbackStoredEmail = fallbackAccount.email; + const hadFallbackAccountId = + !!fallbackStoredAccountId; + const fallbackRuntimeIdentity = + resolveRuntimeRequestIdentity({ + storedAccountId: fallbackStoredAccountId, + source: fallbackStoredAccountIdSource, + storedEmail: fallbackStoredEmail, + accessToken: fallbackAuth.access, + idToken: fallbackAuth.idToken, + }); + const fallbackTokenAccountId = + fallbackRuntimeIdentity.tokenAccountId; + const fallbackAccountId = + fallbackRuntimeIdentity.accountId; + if (!fallbackAccountId) { + continue; + } + const fallbackResolvedEmail = + fallbackRuntimeIdentity.email; + const fallbackEntitlementAccountKey = + resolveEntitlementAccountKey({ + accountId: + fallbackStoredAccountId ?? + fallbackAccountId, + email: fallbackResolvedEmail, + refreshToken: fallbackAccount.refreshToken, + index: fallbackAccount.index, + }); + const fallbackEntitlementBlock = + entitlementCache.isBlocked( + fallbackEntitlementAccountKey, + model ?? modelFamily, + ); + if (fallbackEntitlementBlock.blocked) { + runtimeMetrics.accountRotations++; + runtimeMetrics.lastError = `Entitlement cached block for account ${fallbackAccount.index + 1}`; + logWarn( + `Skipping account ${fallbackAccount.index + 1} due to cached entitlement block (${formatWaitTime(fallbackEntitlementBlock.waitMs)} remaining).`, + ); + continue; + } + + if ( + !accountManager.consumeToken( + fallbackAccount, + modelFamily, + model, + ) + ) { + continue; + } + fallbackAccount.accountId = fallbackAccountId; + if ( + !hadFallbackAccountId && + fallbackTokenAccountId && + fallbackAccountId === fallbackTokenAccountId + ) { + fallbackAccount.accountIdSource = + fallbackStoredAccountIdSource ?? "token"; + } + if (fallbackResolvedEmail) { + fallbackAccount.email = fallbackResolvedEmail; + } + + const fallbackHeaders = createCodexHeaders( + requestInit, + fallbackAccountId, + fallbackAuth.access, + { + model, + promptCacheKey: effectivePromptCacheKey, + }, + ); + + const fallbackController = new AbortController(); + const fallbackTimeoutId = setTimeout( + () => + fallbackController.abort( + new Error("Request timeout"), + ), + fetchTimeoutMs, + ); + const onFallbackAbort = abortSignal + ? () => + fallbackController.abort( + abortSignal.reason ?? + new Error("Aborted by user"), + ) + : null; + if (abortSignal && onFallbackAbort) { + abortSignal.addEventListener( + "abort", + onFallbackAbort, + { + once: true, + }, + ); + } + + try { + runtimeMetrics.totalRequests++; + const fallbackResponse = await fetch( + url, + applyProxyCompatibleInit(url, { + ...requestInit, + headers: fallbackHeaders, + signal: fallbackController.signal, + }), + ); + const fallbackSnapshot = + readQuotaSchedulerSnapshot( + fallbackResponse.headers, + fallbackResponse.status, + ); + if (fallbackSnapshot) { + preemptiveQuotaScheduler.update( + `${fallbackEntitlementAccountKey}:${model ?? modelFamily}`, + fallbackSnapshot, + ); + } + if (!fallbackResponse.ok) { + try { + await fallbackResponse.body?.cancel(); + } catch { + // Best effort cleanup before trying next fallback account. + } + if (fallbackResponse.status === 429) { + const retryAfterMs = + parseRetryAfterHintMs( + fallbackResponse.headers, + ) ?? 60_000; + accountManager.markRateLimitedWithReason( + fallbackAccount, + retryAfterMs, + modelFamily, + "quota", + model, + ); + accountManager.recordRateLimit( + fallbackAccount, + modelFamily, + model, + ); + } else { + accountManager.recordFailure( + fallbackAccount, + modelFamily, + model, + ); + } + capabilityPolicyStore.recordFailure( + fallbackEntitlementAccountKey, + capabilityModelKey, + ); + continue; + } + + successAccountForResponse = fallbackAccount; + successEntitlementAccountKey = + fallbackEntitlementAccountKey; + runtimeMetrics.streamFailoverRecoveries += 1; + if (fallbackAccount.index !== account.index) { + runtimeMetrics.streamFailoverCrossAccountRecoveries += 1; + runtimeMetrics.accountRotations += 1; + sessionAffinityStore?.remember( + sessionAffinityKey, + fallbackAccount.index, + ); + } + + logInfo( + `Recovered stream via failover attempt ${failoverAttempt} using account ${fallbackAccount.index + 1}.`, + { emittedBytes }, + ); + return fallbackResponse; + } catch (streamFailoverError) { + accountManager.refundToken( + fallbackAccount, + modelFamily, + model, + ); + accountManager.recordFailure( + fallbackAccount, + modelFamily, + model, + ); + capabilityPolicyStore.recordFailure( + fallbackEntitlementAccountKey, + capabilityModelKey, + ); + logWarn( + `Stream failover attempt ${failoverAttempt} failed for account ${fallbackAccount.index + 1}.`, + { + emittedBytes, + error: + streamFailoverError instanceof Error + ? streamFailoverError.message + : String(streamFailoverError), + }, + ); + } finally { + clearTimeout(fallbackTimeoutId); + if (abortSignal && onFallbackAbort) { + abortSignal.removeEventListener( + "abort", + onFallbackAbort, + ); + } + } + } + + return null; + }, + { + maxFailovers: streamFailoverMax, + softTimeoutMs: streamFailoverSoftTimeoutMs, + hardTimeoutMs: streamFailoverHardTimeoutMs, + requestInstanceId: + requestCorrelationId ?? undefined, + }, ); } + const successResponse = await handleSuccessResponse( + responseForSuccess, + isStreaming, + { + streamStallTimeoutMs, + }, + ); - logInfo( - `Recovered stream via failover attempt ${failoverAttempt} using account ${fallbackAccount.index + 1}.`, - { emittedBytes }, + if (!isStreaming && emptyResponseMaxRetries > 0) { + const clonedResponse = successResponse.clone(); + try { + const bodyText = await clonedResponse.text(); + const parsedBody = bodyText + ? (JSON.parse(bodyText) as unknown) + : null; + if (isEmptyResponse(parsedBody)) { + if ( + emptyResponseRetries < emptyResponseMaxRetries + ) { + emptyResponseRetries++; + runtimeMetrics.emptyResponseRetries++; + logWarn( + `Empty response received (attempt ${emptyResponseRetries}/${emptyResponseMaxRetries}). Retrying...`, + ); + await showToast( + `Empty response. Retrying (${emptyResponseRetries}/${emptyResponseMaxRetries})...`, + "warning", + { duration: toastDurationMs }, + ); + accountManager.refundToken( + account, + modelFamily, + model, + ); + accountManager.recordFailure( + account, + modelFamily, + model, + ); + capabilityPolicyStore.recordFailure( + entitlementAccountKey, + capabilityModelKey, + ); + const emptyPolicy = evaluateFailurePolicy({ + kind: "empty-response", + failoverMode, + }); + if ( + emptyPolicy.retrySameAccount && + sameAccountRetryCount < maxSameAccountRetries + ) { + sameAccountRetryCount += 1; + runtimeMetrics.sameAccountRetries += 1; + const retryDelayMs = Math.max( + 0, + Math.floor( + emptyPolicy.retryDelayMs ?? + emptyResponseRetryDelayMs, + ), + ); + if (retryDelayMs > 0) { + await sleep(addJitter(retryDelayMs, 0.2)); + } + continue; + } + sessionAffinityStore?.forgetSession( + sessionAffinityKey, + ); + await sleep( + addJitter(emptyResponseRetryDelayMs, 0.2), + ); + break; + } + logWarn( + `Empty response after ${emptyResponseMaxRetries} retries. Returning as-is.`, + ); + } + } catch { + // Intentionally empty: non-JSON response bodies should be returned as-is + } + } + + if (successAccountForResponse.index !== account.index) { + accountManager.markSwitched( + successAccountForResponse, + "rotation", + modelFamily, + ); + } + const successAccountKey = successEntitlementAccountKey; + accountManager.recordSuccess( + successAccountForResponse, + modelFamily, + model, ); - return fallbackResponse; - } catch (streamFailoverError) { - accountManager.refundToken(fallbackAccount, modelFamily, model); - accountManager.recordFailure(fallbackAccount, modelFamily, model); - capabilityPolicyStore.recordFailure( - fallbackEntitlementAccountKey, + capabilityPolicyStore.recordSuccess( + successAccountKey, capabilityModelKey, ); - logWarn( - `Stream failover attempt ${failoverAttempt} failed for account ${fallbackAccount.index + 1}.`, - { - emittedBytes, - error: - streamFailoverError instanceof Error - ? streamFailoverError.message - : String(streamFailoverError), - }, + entitlementCache.clear( + successAccountKey, + capabilityModelKey, ); - continue; - } finally { - clearTimeout(fallbackTimeoutId); - if (abortSignal && onFallbackAbort) { - abortSignal.removeEventListener("abort", onFallbackAbort); + sessionAffinityStore?.remember( + sessionAffinityKey, + successAccountForResponse.index, + ); + runtimeMetrics.successfulRequests++; + runtimeMetrics.lastError = null; + if ( + lastCodexCliActiveSyncIndex !== + successAccountForResponse.index + ) { + void accountManager.syncCodexCliActiveSelectionForIndex( + successAccountForResponse.index, + ); + lastCodexCliActiveSyncIndex = + successAccountForResponse.index; } + return successResponse; } - } - - return null; - }, - { - maxFailovers: streamFailoverMax, - softTimeoutMs: streamFailoverSoftTimeoutMs, - hardTimeoutMs: streamFailoverHardTimeoutMs, - requestInstanceId: requestCorrelationId ?? undefined, - }, - ); - } - const successResponse = await handleSuccessResponse(responseForSuccess, isStreaming, { - streamStallTimeoutMs, - }); - - if (!isStreaming && emptyResponseMaxRetries > 0) { - const clonedResponse = successResponse.clone(); - try { - const bodyText = await clonedResponse.text(); - const parsedBody = bodyText ? JSON.parse(bodyText) as unknown : null; - if (isEmptyResponse(parsedBody)) { - if (emptyResponseRetries < emptyResponseMaxRetries) { - emptyResponseRetries++; - runtimeMetrics.emptyResponseRetries++; - logWarn(`Empty response received (attempt ${emptyResponseRetries}/${emptyResponseMaxRetries}). Retrying...`); - await showToast( - `Empty response. Retrying (${emptyResponseRetries}/${emptyResponseMaxRetries})...`, - "warning", - { duration: toastDurationMs }, - ); - accountManager.refundToken(account, modelFamily, model); - accountManager.recordFailure(account, modelFamily, model); - capabilityPolicyStore.recordFailure( - entitlementAccountKey, - capabilityModelKey, - ); - const emptyPolicy = evaluateFailurePolicy({ - kind: "empty-response", - failoverMode, - }); - if ( - emptyPolicy.retrySameAccount && - sameAccountRetryCount < maxSameAccountRetries - ) { - sameAccountRetryCount += 1; - runtimeMetrics.sameAccountRetries += 1; - const retryDelayMs = Math.max( - 0, - Math.floor(emptyPolicy.retryDelayMs ?? emptyResponseRetryDelayMs), - ); - if (retryDelayMs > 0) { - await sleep(addJitter(retryDelayMs, 0.2)); - } - continue; - } - sessionAffinityStore?.forgetSession(sessionAffinityKey); - await sleep(addJitter(emptyResponseRetryDelayMs, 0.2)); - break; - } - logWarn(`Empty response after ${emptyResponseMaxRetries} retries. Returning as-is.`); - } - } catch { - // Intentionally empty: non-JSON response bodies should be returned as-is - } - } - - if (successAccountForResponse.index !== account.index) { - accountManager.markSwitched(successAccountForResponse, "rotation", modelFamily); - } - const successAccountKey = successEntitlementAccountKey; - accountManager.recordSuccess(successAccountForResponse, modelFamily, model); - capabilityPolicyStore.recordSuccess( - successAccountKey, - capabilityModelKey, - ); - entitlementCache.clear(successAccountKey, capabilityModelKey); - sessionAffinityStore?.remember( - sessionAffinityKey, - successAccountForResponse.index, - ); - runtimeMetrics.successfulRequests++; - runtimeMetrics.lastError = null; - if (lastCodexCliActiveSyncIndex !== successAccountForResponse.index) { - void accountManager.syncCodexCliActiveSelectionForIndex(successAccountForResponse.index); - lastCodexCliActiveSyncIndex = successAccountForResponse.index; - } - return successResponse; - } if (retryNextAccountBeforeFallback) { retryNextAccountBeforeFallback = false; continue; @@ -2532,523 +2850,581 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { if (restartAccountTraversalWithFallback) { break; } - } - - if (restartAccountTraversalWithFallback) { - continue; - } + } - const waitMs = accountManager.getMinWaitTimeForFamily(modelFamily, model); - const count = accountManager.getAccountCount(); + if (restartAccountTraversalWithFallback) { + continue; + } - if ( - retryAllAccountsRateLimited && - count > 0 && - waitMs > 0 && - (retryAllAccountsMaxWaitMs === 0 || - waitMs <= retryAllAccountsMaxWaitMs) && - allRateLimitedRetries < retryAllAccountsMaxRetries - ) { - const countdownMessage = `All ${count} account(s) rate-limited. Waiting`; - await sleepWithCountdown(addJitter(waitMs, 0.2), countdownMessage); - allRateLimitedRetries++; - continue; - } + const waitMs = accountManager.getMinWaitTimeForFamily( + modelFamily, + model, + ); + const count = accountManager.getAccountCount(); - const waitLabel = waitMs > 0 ? formatWaitTime(waitMs) : "a bit"; - const message = - count === 0 - ? "No Codex accounts configured. Run `codex login`." - : waitMs > 0 - ? `All ${count} account(s) are rate-limited. Try again in ${waitLabel} or add another account with \`codex login\`.` - : `All ${count} account(s) failed (server errors or auth issues). Check account health with \`codex-health\`.`; - runtimeMetrics.failedRequests++; - runtimeMetrics.lastError = message; - return new Response(JSON.stringify({ error: { message } }), { - status: waitMs > 0 ? 429 : 503, - headers: { - "content-type": "application/json; charset=utf-8", - }, - }); + if ( + retryAllAccountsRateLimited && + count > 0 && + waitMs > 0 && + (retryAllAccountsMaxWaitMs === 0 || + waitMs <= retryAllAccountsMaxWaitMs) && + allRateLimitedRetries < retryAllAccountsMaxRetries + ) { + const countdownMessage = `All ${count} account(s) rate-limited. Waiting`; + await sleepWithCountdown( + addJitter(waitMs, 0.2), + countdownMessage, + ); + allRateLimitedRetries++; + continue; } - } finally { - clearCorrelationId(); - } + + const waitLabel = + waitMs > 0 ? formatWaitTime(waitMs) : "a bit"; + const message = + count === 0 + ? "No Codex accounts configured. Run `codex login`." + : waitMs > 0 + ? `All ${count} account(s) are rate-limited. Try again in ${waitLabel} or add another account with \`codex login\`.` + : `All ${count} account(s) failed (server errors or auth issues). Check account health with \`codex-health\`.`; + runtimeMetrics.failedRequests++; + runtimeMetrics.lastError = message; + return new Response(JSON.stringify({ error: { message } }), { + status: waitMs > 0 ? 429 : 503, + headers: { + "content-type": "application/json; charset=utf-8", }, - }; + }); + } + } finally { + clearCorrelationId(); + } + }, + }; } finally { resolveMutex?.(); loaderMutex = null; } - }, - methods: [ - { - label: AUTH_LABELS.OAUTH, - type: "oauth" as const, - authorize: async (inputs?: Record) => { - const authPluginConfig = loadPluginConfig(); - applyUiRuntimeFromConfig(authPluginConfig); - applyAccountStorageScope(authPluginConfig); - - const accounts: TokenSuccessWithAccount[] = []; - const noBrowser = - inputs?.manual === "true" || - inputs?.noBrowser === "true" || - inputs?.["no-browser"] === "true"; - const useManualMode = noBrowser || isBrowserLaunchSuppressed(); - const explicitLoginMode = - inputs?.loginMode === "fresh" || inputs?.loginMode === "add" - ? inputs.loginMode - : null; - - let startFresh = explicitLoginMode === "fresh"; - let refreshAccountIndex: number | undefined; - - const clampActiveIndices = (storage: AccountStorageV3): void => { - const count = storage.accounts.length; - if (count === 0) { - storage.activeIndex = 0; - storage.activeIndexByFamily = {}; - return; - } - storage.activeIndex = Math.max(0, Math.min(storage.activeIndex, count - 1)); - storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; - for (const family of MODEL_FAMILIES) { - const raw = storage.activeIndexByFamily[family]; - const candidate = - typeof raw === "number" && Number.isFinite(raw) ? raw : storage.activeIndex; - storage.activeIndexByFamily[family] = Math.max(0, Math.min(candidate, count - 1)); - } - }; - - const isFlaggableFailure = (failure: Extract): boolean => { - if (failure.reason === "missing_refresh") return true; - if (failure.statusCode === 401) return true; - if (failure.statusCode !== 400) return false; - const message = (failure.message ?? "").toLowerCase(); - return ( - message.includes("invalid_grant") || - message.includes("invalid refresh") || - message.includes("token has been revoked") + }, + methods: [ + { + label: AUTH_LABELS.OAUTH, + type: "oauth" as const, + authorize: async (inputs?: Record) => { + const authPluginConfig = loadPluginConfig(); + applyUiRuntimeFromConfig(authPluginConfig); + applyAccountStorageScope(authPluginConfig); + + const accounts: TokenSuccessWithAccount[] = []; + const noBrowser = + inputs?.manual === "true" || + inputs?.noBrowser === "true" || + inputs?.["no-browser"] === "true"; + const useManualMode = noBrowser || isBrowserLaunchSuppressed(); + const explicitLoginMode = + inputs?.loginMode === "fresh" || inputs?.loginMode === "add" + ? inputs.loginMode + : null; + + let startFresh = explicitLoginMode === "fresh"; + let refreshAccountIndex: number | undefined; + + const clampActiveIndices = (storage: AccountStorageV3): void => { + const count = storage.accounts.length; + if (count === 0) { + storage.activeIndex = 0; + storage.activeIndexByFamily = {}; + return; + } + storage.activeIndex = Math.max( + 0, + Math.min(storage.activeIndex, count - 1), + ); + storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; + for (const family of MODEL_FAMILIES) { + const raw = storage.activeIndexByFamily[family]; + const candidate = + typeof raw === "number" && Number.isFinite(raw) + ? raw + : storage.activeIndex; + storage.activeIndexByFamily[family] = Math.max( + 0, + Math.min(candidate, count - 1), ); - }; + } + }; - type CodexQuotaWindow = { - usedPercent?: number; - windowMinutes?: number; - resetAtMs?: number; - }; + const isFlaggableFailure = ( + failure: Extract, + ): boolean => { + if (failure.reason === "missing_refresh") return true; + if (failure.statusCode === 401) return true; + if (failure.statusCode !== 400) return false; + const message = (failure.message ?? "").toLowerCase(); + return ( + message.includes("invalid_grant") || + message.includes("invalid refresh") || + message.includes("token has been revoked") + ); + }; - type CodexQuotaSnapshot = { - status: number; - planType?: string; - activeLimit?: number; - primary: CodexQuotaWindow; - secondary: CodexQuotaWindow; - }; + type CodexQuotaWindow = { + usedPercent?: number; + windowMinutes?: number; + resetAtMs?: number; + }; - const parseFiniteNumberHeader = (headers: Headers, name: string): number | undefined => { - const raw = headers.get(name); - if (!raw) return undefined; - const parsed = Number(raw); - return Number.isFinite(parsed) ? parsed : undefined; - }; + type CodexQuotaSnapshot = { + status: number; + planType?: string; + activeLimit?: number; + primary: CodexQuotaWindow; + secondary: CodexQuotaWindow; + }; - const parseFiniteIntHeader = (headers: Headers, name: string): number | undefined => { - const raw = headers.get(name); - if (!raw) return undefined; - const parsed = Number.parseInt(raw, 10); - return Number.isFinite(parsed) ? parsed : undefined; - }; + const parseFiniteNumberHeader = ( + headers: Headers, + name: string, + ): number | undefined => { + const raw = headers.get(name); + if (!raw) return undefined; + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : undefined; + }; - const parseResetAtMs = (headers: Headers, prefix: string): number | undefined => { - const resetAfterSeconds = parseFiniteIntHeader( - headers, - `${prefix}-reset-after-seconds`, - ); - if ( - typeof resetAfterSeconds === "number" && - Number.isFinite(resetAfterSeconds) && - resetAfterSeconds > 0 - ) { - return Date.now() + resetAfterSeconds * 1000; - } + const parseFiniteIntHeader = ( + headers: Headers, + name: string, + ): number | undefined => { + const raw = headers.get(name); + if (!raw) return undefined; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) ? parsed : undefined; + }; - const resetAtRaw = headers.get(`${prefix}-reset-at`); - if (!resetAtRaw) return undefined; + const parseResetAtMs = ( + headers: Headers, + prefix: string, + ): number | undefined => { + const resetAfterSeconds = parseFiniteIntHeader( + headers, + `${prefix}-reset-after-seconds`, + ); + if ( + typeof resetAfterSeconds === "number" && + Number.isFinite(resetAfterSeconds) && + resetAfterSeconds > 0 + ) { + return Date.now() + resetAfterSeconds * 1000; + } - const trimmed = resetAtRaw.trim(); - if (/^\d+$/.test(trimmed)) { - const parsedNumber = Number.parseInt(trimmed, 10); - if (Number.isFinite(parsedNumber) && parsedNumber > 0) { - // Upstream sometimes returns seconds since epoch. - return parsedNumber < 10_000_000_000 ? parsedNumber * 1000 : parsedNumber; - } + const resetAtRaw = headers.get(`${prefix}-reset-at`); + if (!resetAtRaw) return undefined; + + const trimmed = resetAtRaw.trim(); + if (/^\d+$/.test(trimmed)) { + const parsedNumber = Number.parseInt(trimmed, 10); + if (Number.isFinite(parsedNumber) && parsedNumber > 0) { + // Upstream sometimes returns seconds since epoch. + return parsedNumber < 10_000_000_000 + ? parsedNumber * 1000 + : parsedNumber; } + } - const parsedDate = Date.parse(trimmed); - return Number.isFinite(parsedDate) ? parsedDate : undefined; - }; - - const hasCodexQuotaHeaders = (headers: Headers): boolean => { - const keys = [ - "x-codex-primary-used-percent", - "x-codex-primary-window-minutes", - "x-codex-primary-reset-at", - "x-codex-primary-reset-after-seconds", - "x-codex-secondary-used-percent", - "x-codex-secondary-window-minutes", - "x-codex-secondary-reset-at", - "x-codex-secondary-reset-after-seconds", - ]; - return keys.some((key) => headers.get(key) !== null); - }; - - const parseCodexQuotaSnapshot = (headers: Headers, status: number): CodexQuotaSnapshot | null => { - if (!hasCodexQuotaHeaders(headers)) return null; + const parsedDate = Date.parse(trimmed); + return Number.isFinite(parsedDate) ? parsedDate : undefined; + }; - const primaryPrefix = "x-codex-primary"; - const secondaryPrefix = "x-codex-secondary"; - const primary: CodexQuotaWindow = { - usedPercent: parseFiniteNumberHeader(headers, `${primaryPrefix}-used-percent`), - windowMinutes: parseFiniteIntHeader(headers, `${primaryPrefix}-window-minutes`), - resetAtMs: parseResetAtMs(headers, primaryPrefix), - }; - const secondary: CodexQuotaWindow = { - usedPercent: parseFiniteNumberHeader(headers, `${secondaryPrefix}-used-percent`), - windowMinutes: parseFiniteIntHeader(headers, `${secondaryPrefix}-window-minutes`), - resetAtMs: parseResetAtMs(headers, secondaryPrefix), - }; + const hasCodexQuotaHeaders = (headers: Headers): boolean => { + const keys = [ + "x-codex-primary-used-percent", + "x-codex-primary-window-minutes", + "x-codex-primary-reset-at", + "x-codex-primary-reset-after-seconds", + "x-codex-secondary-used-percent", + "x-codex-secondary-window-minutes", + "x-codex-secondary-reset-at", + "x-codex-secondary-reset-after-seconds", + ]; + return keys.some((key) => headers.get(key) !== null); + }; - const planTypeRaw = headers.get("x-codex-plan-type"); - const planType = planTypeRaw && planTypeRaw.trim() ? planTypeRaw.trim() : undefined; - const activeLimit = parseFiniteIntHeader(headers, "x-codex-active-limit"); + const parseCodexQuotaSnapshot = ( + headers: Headers, + status: number, + ): CodexQuotaSnapshot | null => { + if (!hasCodexQuotaHeaders(headers)) return null; - return { status, planType, activeLimit, primary, secondary }; + const primaryPrefix = "x-codex-primary"; + const secondaryPrefix = "x-codex-secondary"; + const primary: CodexQuotaWindow = { + usedPercent: parseFiniteNumberHeader( + headers, + `${primaryPrefix}-used-percent`, + ), + windowMinutes: parseFiniteIntHeader( + headers, + `${primaryPrefix}-window-minutes`, + ), + resetAtMs: parseResetAtMs(headers, primaryPrefix), }; - - const formatQuotaWindowLabel = (windowMinutes: number | undefined): string => { - if (!windowMinutes || !Number.isFinite(windowMinutes) || windowMinutes <= 0) { - return "quota"; - } - if (windowMinutes % 1440 === 0) return `${windowMinutes / 1440}d`; - if (windowMinutes % 60 === 0) return `${windowMinutes / 60}h`; - return `${windowMinutes}m`; + const secondary: CodexQuotaWindow = { + usedPercent: parseFiniteNumberHeader( + headers, + `${secondaryPrefix}-used-percent`, + ), + windowMinutes: parseFiniteIntHeader( + headers, + `${secondaryPrefix}-window-minutes`, + ), + resetAtMs: parseResetAtMs(headers, secondaryPrefix), }; - const formatResetAt = (resetAtMs: number | undefined): string | undefined => { - if (!resetAtMs || !Number.isFinite(resetAtMs) || resetAtMs <= 0) return undefined; - const date = new Date(resetAtMs); - if (!Number.isFinite(date.getTime())) return undefined; - - const now = new Date(); - const sameDay = - now.getFullYear() === date.getFullYear() && - now.getMonth() === date.getMonth() && - now.getDate() === date.getDate(); - - const time = date.toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - hour12: false, - }); + const planTypeRaw = headers.get("x-codex-plan-type"); + const planType = + planTypeRaw && planTypeRaw.trim() + ? planTypeRaw.trim() + : undefined; + const activeLimit = parseFiniteIntHeader( + headers, + "x-codex-active-limit", + ); - if (sameDay) return time; - const day = date.toLocaleDateString(undefined, { month: "short", day: "2-digit" }); - return `${time} on ${day}`; - }; + return { status, planType, activeLimit, primary, secondary }; + }; - const formatCodexQuotaLine = (snapshot: CodexQuotaSnapshot): string => { - const summarizeWindow = (label: string, window: CodexQuotaWindow): string => { - const used = window.usedPercent; - const left = - typeof used === "number" && Number.isFinite(used) - ? Math.max(0, Math.min(100, Math.round(100 - used))) - : undefined; - const reset = formatResetAt(window.resetAtMs); - let summary = label; - if (left !== undefined) summary = `${summary} ${left}% left`; - if (reset) summary = `${summary} (resets ${reset})`; - return summary; - }; + const formatQuotaWindowLabel = ( + windowMinutes: number | undefined, + ): string => { + if ( + !windowMinutes || + !Number.isFinite(windowMinutes) || + windowMinutes <= 0 + ) { + return "quota"; + } + if (windowMinutes % 1440 === 0) return `${windowMinutes / 1440}d`; + if (windowMinutes % 60 === 0) return `${windowMinutes / 60}h`; + return `${windowMinutes}m`; + }; - const primaryLabel = formatQuotaWindowLabel(snapshot.primary.windowMinutes); - const secondaryLabel = formatQuotaWindowLabel(snapshot.secondary.windowMinutes); - const parts = [ - summarizeWindow(primaryLabel, snapshot.primary), - summarizeWindow(secondaryLabel, snapshot.secondary), - ]; - if (snapshot.planType) parts.push(`plan:${snapshot.planType}`); - if (typeof snapshot.activeLimit === "number" && Number.isFinite(snapshot.activeLimit)) { - parts.push(`active:${snapshot.activeLimit}`); - } - if (snapshot.status === 429) parts.push("rate-limited"); - return parts.join(", "); + const formatResetAt = ( + resetAtMs: number | undefined, + ): string | undefined => { + if (!resetAtMs || !Number.isFinite(resetAtMs) || resetAtMs <= 0) + return undefined; + const date = new Date(resetAtMs); + if (!Number.isFinite(date.getTime())) return undefined; + + const now = new Date(); + const sameDay = + now.getFullYear() === date.getFullYear() && + now.getMonth() === date.getMonth() && + now.getDate() === date.getDate(); + + const time = date.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + + if (sameDay) return time; + const day = date.toLocaleDateString(undefined, { + month: "short", + day: "2-digit", + }); + return `${time} on ${day}`; + }; + + const formatCodexQuotaLine = ( + snapshot: CodexQuotaSnapshot, + ): string => { + const summarizeWindow = ( + label: string, + window: CodexQuotaWindow, + ): string => { + const used = window.usedPercent; + const left = + typeof used === "number" && Number.isFinite(used) + ? Math.max(0, Math.min(100, Math.round(100 - used))) + : undefined; + const reset = formatResetAt(window.resetAtMs); + let summary = label; + if (left !== undefined) summary = `${summary} ${left}% left`; + if (reset) summary = `${summary} (resets ${reset})`; + return summary; }; - const fetchCodexQuotaSnapshot = async (params: { - accountId: string; - accessToken: string; - }): Promise => { - const QUOTA_PROBE_MODELS = ["gpt-5-codex", "gpt-5.3-codex", "gpt-5.2-codex"]; - let lastError: Error | null = null; + const primaryLabel = formatQuotaWindowLabel( + snapshot.primary.windowMinutes, + ); + const secondaryLabel = formatQuotaWindowLabel( + snapshot.secondary.windowMinutes, + ); + const parts = [ + summarizeWindow(primaryLabel, snapshot.primary), + summarizeWindow(secondaryLabel, snapshot.secondary), + ]; + if (snapshot.planType) parts.push(`plan:${snapshot.planType}`); + if ( + typeof snapshot.activeLimit === "number" && + Number.isFinite(snapshot.activeLimit) + ) { + parts.push(`active:${snapshot.activeLimit}`); + } + if (snapshot.status === 429) parts.push("rate-limited"); + return parts.join(", "); + }; - for (const model of QUOTA_PROBE_MODELS) { - try { - const instructions = await getCodexInstructions(model); - const probeBody: RequestBody = { - model, - stream: true, - store: false, - include: ["reasoning.encrypted_content"], - instructions, - input: [ - { - type: "message", - role: "user", - content: [{ type: "input_text", text: "quota ping" }], - }, - ], - reasoning: { effort: "none", summary: "auto" }, - text: { verbosity: "low" }, - }; + const fetchCodexQuotaSnapshot = async (params: { + accountId: string; + accessToken: string; + }): Promise => { + const QUOTA_PROBE_MODELS = [ + "gpt-5-codex", + "gpt-5.3-codex", + "gpt-5.2-codex", + ]; + let lastError: Error | null = null; + + for (const model of QUOTA_PROBE_MODELS) { + try { + const instructions = await getCodexInstructions(model); + const probeBody: RequestBody = { + model, + stream: true, + store: false, + include: ["reasoning.encrypted_content"], + instructions, + input: [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "quota ping" }], + }, + ], + reasoning: { effort: "none", summary: "auto" }, + text: { verbosity: "low" }, + }; - const headers = createCodexHeaders(undefined, params.accountId, params.accessToken, { + const headers = createCodexHeaders( + undefined, + params.accountId, + params.accessToken, + { model, - }); - headers.set("content-type", "application/json; charset=utf-8"); + }, + ); + headers.set( + "content-type", + "application/json; charset=utf-8", + ); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 15_000); - let response: Response; - try { - response = await fetch(`${CODEX_BASE_URL}/codex/responses`, { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15_000); + let response: Response; + try { + response = await fetch( + `${CODEX_BASE_URL}/codex/responses`, + { method: "POST", headers, body: JSON.stringify(probeBody), signal: controller.signal, - }); - } finally { - clearTimeout(timeout); - } + }, + ); + } finally { + clearTimeout(timeout); + } - const snapshot = parseCodexQuotaSnapshot(response.headers, response.status); - if (snapshot) { - // We only need headers; cancel the SSE stream immediately. - try { - await response.body?.cancel(); - } catch { - // Ignore cancellation failures. - } - return snapshot; + const snapshot = parseCodexQuotaSnapshot( + response.headers, + response.status, + ); + if (snapshot) { + // We only need headers; cancel the SSE stream immediately. + try { + await response.body?.cancel(); + } catch { + // Ignore cancellation failures. } + return snapshot; + } - if (!response.ok) { - const bodyText = await response.text().catch(() => ""); - let errorBody: unknown = undefined; - try { - errorBody = bodyText ? (JSON.parse(bodyText) as unknown) : undefined; - } catch { - errorBody = { error: { message: bodyText } }; - } - - const unsupportedInfo = getUnsupportedCodexModelInfo(errorBody); - if (unsupportedInfo.isUnsupported) { - lastError = new Error( - unsupportedInfo.message ?? `Model '${model}' unsupported for this account`, - ); - continue; - } + if (!response.ok) { + const bodyText = await response.text().catch(() => ""); + let errorBody: unknown; + try { + errorBody = bodyText + ? (JSON.parse(bodyText) as unknown) + : undefined; + } catch { + errorBody = { error: { message: bodyText } }; + } - const message = - (typeof (errorBody as { error?: { message?: unknown } })?.error?.message === "string" - ? (errorBody as { error?: { message?: string } }).error?.message - : bodyText) || `HTTP ${response.status}`; - throw new Error(message); + const unsupportedInfo = + getUnsupportedCodexModelInfo(errorBody); + if (unsupportedInfo.isUnsupported) { + lastError = new Error( + unsupportedInfo.message ?? + `Model '${model}' unsupported for this account`, + ); + continue; } - lastError = new Error("Codex response did not include quota headers"); - } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); + const message = + (typeof (errorBody as { error?: { message?: unknown } }) + ?.error?.message === "string" + ? (errorBody as { error?: { message?: string } }).error + ?.message + : bodyText) || `HTTP ${response.status}`; + throw new Error(message); } + + lastError = new Error( + "Codex response did not include quota headers", + ); + } catch (error) { + lastError = + error instanceof Error ? error : new Error(String(error)); } + } - throw lastError ?? new Error("Failed to fetch quotas"); - }; + throw lastError ?? new Error("Failed to fetch quotas"); + }; - const runAccountCheck = async (deepProbe: boolean): Promise => { - const loadedStorage = await hydrateEmails(await loadAccounts()); - const workingStorage = loadedStorage - ? { + const runAccountCheck = async ( + deepProbe: boolean, + ): Promise => { + const loadedStorage = await hydrateEmails(await loadAccounts()); + const workingStorage = loadedStorage + ? { ...loadedStorage, - accounts: loadedStorage.accounts.map((account) => ({ ...account })), + accounts: loadedStorage.accounts.map((account) => ({ + ...account, + })), activeIndexByFamily: loadedStorage.activeIndexByFamily ? { ...loadedStorage.activeIndexByFamily } : {}, } - : { version: 3 as const, accounts: [], activeIndex: 0, activeIndexByFamily: {} }; + : { + version: 3 as const, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }; - if (workingStorage.accounts.length === 0) { - console.log("\nNo accounts to check.\n"); - return; - } + if (workingStorage.accounts.length === 0) { + console.log("\nNo accounts to check.\n"); + return; + } - const flaggedStorage = await loadFlaggedAccounts(); - let storageChanged = false; - let flaggedChanged = false; - const removeFromActive = new Set(); - const total = workingStorage.accounts.length; - let ok = 0; - let disabled = 0; - let errors = 0; + const flaggedStorage = await loadFlaggedAccounts(); + let storageChanged = false; + let flaggedChanged = false; + const removeFromActive = new Set(); + const total = workingStorage.accounts.length; + let ok = 0; + let disabled = 0; + let errors = 0; + + console.log( + `\nChecking ${deepProbe ? "full account health" : "quotas"} for all accounts...\n`, + ); - console.log( - `\nChecking ${deepProbe ? "full account health" : "quotas"} for all accounts...\n`, - ); + for (let i = 0; i < total; i += 1) { + const account = workingStorage.accounts[i]; + if (!account) continue; + const label = + account.email ?? account.accountLabel ?? `Account ${i + 1}`; + if (account.enabled === false) { + disabled += 1; + console.log(`[${i + 1}/${total}] ${label}: DISABLED`); + continue; + } - for (let i = 0; i < total; i += 1) { - const account = workingStorage.accounts[i]; - if (!account) continue; - const label = account.email ?? account.accountLabel ?? `Account ${i + 1}`; - if (account.enabled === false) { - disabled += 1; - console.log(`[${i + 1}/${total}] ${label}: DISABLED`); - continue; + try { + // If we already have a valid cached access token, don't force-refresh. + // This avoids flagging accounts where the refresh token has been burned + // but the access token is still valid (same behavior as Codex CLI). + const nowMs = Date.now(); + let accessToken: string | null = null; + let tokenAccountId: string | undefined; + let authDetail = "OK"; + if ( + account.accessToken && + (typeof account.expiresAt !== "number" || + !Number.isFinite(account.expiresAt) || + account.expiresAt > nowMs) + ) { + accessToken = account.accessToken; + authDetail = "OK (cached access)"; + + tokenAccountId = extractAccountId(account.accessToken); + if ( + tokenAccountId && + shouldUpdateAccountIdFromToken( + account.accountIdSource, + account.accountId, + ) && + tokenAccountId !== account.accountId + ) { + account.accountId = tokenAccountId; + account.accountIdSource = "token"; + storageChanged = true; + } } - try { - // If we already have a valid cached access token, don't force-refresh. - // This avoids flagging accounts where the refresh token has been burned - // but the access token is still valid (same behavior as Codex CLI). - const nowMs = Date.now(); - let accessToken: string | null = null; - let tokenAccountId: string | undefined = undefined; - let authDetail = "OK"; + // If Codex CLI has a valid cached access token for this email, use it + // instead of forcing a refresh. + if (!accessToken) { + const cached = await lookupCodexCliTokensByEmail( + account.email, + ); if ( - account.accessToken && - (typeof account.expiresAt !== "number" || - !Number.isFinite(account.expiresAt) || - account.expiresAt > nowMs) + cached && + (typeof cached.expiresAt !== "number" || + !Number.isFinite(cached.expiresAt) || + cached.expiresAt > nowMs) ) { - accessToken = account.accessToken; - authDetail = "OK (cached access)"; + accessToken = cached.accessToken; + authDetail = "OK (Codex CLI cache)"; - tokenAccountId = extractAccountId(account.accessToken); if ( - tokenAccountId && - shouldUpdateAccountIdFromToken(account.accountIdSource, account.accountId) && - tokenAccountId !== account.accountId + cached.refreshToken && + cached.refreshToken !== account.refreshToken ) { - account.accountId = tokenAccountId; - account.accountIdSource = "token"; + account.refreshToken = cached.refreshToken; storageChanged = true; } - - } - - // If Codex CLI has a valid cached access token for this email, use it - // instead of forcing a refresh. - if (!accessToken) { - const cached = await lookupCodexCliTokensByEmail(account.email); if ( - cached && - (typeof cached.expiresAt !== "number" || - !Number.isFinite(cached.expiresAt) || - cached.expiresAt > nowMs) + cached.accessToken && + cached.accessToken !== account.accessToken ) { - accessToken = cached.accessToken; - authDetail = "OK (Codex CLI cache)"; - - if (cached.refreshToken && cached.refreshToken !== account.refreshToken) { - account.refreshToken = cached.refreshToken; - storageChanged = true; - } - if (cached.accessToken && cached.accessToken !== account.accessToken) { - account.accessToken = cached.accessToken; - storageChanged = true; - } - if (cached.expiresAt !== account.expiresAt) { - account.expiresAt = cached.expiresAt; - storageChanged = true; - } - - const hydratedEmail = sanitizeEmail( - extractAccountEmail(cached.accessToken), - ); - if (hydratedEmail && hydratedEmail !== account.email) { - account.email = hydratedEmail; - storageChanged = true; - } - - tokenAccountId = extractAccountId(cached.accessToken); - if ( - tokenAccountId && - shouldUpdateAccountIdFromToken(account.accountIdSource, account.accountId) && - tokenAccountId !== account.accountId - ) { - account.accountId = tokenAccountId; - account.accountIdSource = "token"; - storageChanged = true; - } - } - } - - if (!accessToken) { - const refreshResult = await queuedRefresh(account.refreshToken); - if (refreshResult.type !== "success") { - errors += 1; - const message = - refreshResult.message ?? refreshResult.reason ?? "refresh failed"; - console.log(`[${i + 1}/${total}] ${label}: ERROR (${message})`); - if (deepProbe && isFlaggableFailure(refreshResult)) { - const existingIndex = flaggedStorage.accounts.findIndex( - (flagged) => flagged.refreshToken === account.refreshToken, - ); - const flaggedRecord: FlaggedAccountMetadataV1 = { - ...account, - flaggedAt: Date.now(), - flaggedReason: "token-invalid", - lastError: message, - }; - if (existingIndex >= 0) { - flaggedStorage.accounts[existingIndex] = flaggedRecord; - } else { - flaggedStorage.accounts.push(flaggedRecord); - } - removeFromActive.add(account.refreshToken); - flaggedChanged = true; - } - continue; - } - - accessToken = refreshResult.access; - authDetail = "OK"; - if (refreshResult.refresh !== account.refreshToken) { - account.refreshToken = refreshResult.refresh; - storageChanged = true; - } - if (refreshResult.access && refreshResult.access !== account.accessToken) { - account.accessToken = refreshResult.access; + account.accessToken = cached.accessToken; storageChanged = true; } - if ( - typeof refreshResult.expires === "number" && - refreshResult.expires !== account.expiresAt - ) { - account.expiresAt = refreshResult.expires; + if (cached.expiresAt !== account.expiresAt) { + account.expiresAt = cached.expiresAt; storageChanged = true; } + const hydratedEmail = sanitizeEmail( - extractAccountEmail(refreshResult.access, refreshResult.idToken), + extractAccountEmail(cached.accessToken), ); if (hydratedEmail && hydratedEmail !== account.email) { account.email = hydratedEmail; storageChanged = true; } - tokenAccountId = extractAccountId(refreshResult.access); + + tokenAccountId = extractAccountId(cached.accessToken); if ( tokenAccountId && - shouldUpdateAccountIdFromToken(account.accountIdSource, account.accountId) && + shouldUpdateAccountIdFromToken( + account.accountIdSource, + account.accountId, + ) && tokenAccountId !== account.accountId ) { account.accountId = tokenAccountId; @@ -3056,200 +3432,316 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { storageChanged = true; } } + } - if (!accessToken) { - throw new Error("Missing access token after refresh"); + if (!accessToken) { + const refreshResult = await queuedRefresh( + account.refreshToken, + ); + if (refreshResult.type !== "success") { + errors += 1; + const message = + refreshResult.message ?? + refreshResult.reason ?? + "refresh failed"; + console.log( + `[${i + 1}/${total}] ${label}: ERROR (${message})`, + ); + if (deepProbe && isFlaggableFailure(refreshResult)) { + const existingIndex = flaggedStorage.accounts.findIndex( + (flagged) => + flagged.refreshToken === account.refreshToken, + ); + const flaggedRecord: FlaggedAccountMetadataV1 = { + ...account, + flaggedAt: Date.now(), + flaggedReason: "token-invalid", + lastError: message, + }; + if (existingIndex >= 0) { + flaggedStorage.accounts[existingIndex] = + flaggedRecord; + } else { + flaggedStorage.accounts.push(flaggedRecord); + } + removeFromActive.add(account.refreshToken); + flaggedChanged = true; + } + continue; } - if (deepProbe) { - ok += 1; - const detail = - tokenAccountId - ? `${authDetail} (id:${tokenAccountId.slice(-6)})` - : authDetail; - console.log(`[${i + 1}/${total}] ${label}: ${detail}`); - continue; + accessToken = refreshResult.access; + authDetail = "OK"; + if (refreshResult.refresh !== account.refreshToken) { + account.refreshToken = refreshResult.refresh; + storageChanged = true; + } + if ( + refreshResult.access && + refreshResult.access !== account.accessToken + ) { + account.accessToken = refreshResult.access; + storageChanged = true; + } + if ( + typeof refreshResult.expires === "number" && + refreshResult.expires !== account.expiresAt + ) { + account.expiresAt = refreshResult.expires; + storageChanged = true; + } + const hydratedEmail = sanitizeEmail( + extractAccountEmail( + refreshResult.access, + refreshResult.idToken, + ), + ); + if (hydratedEmail && hydratedEmail !== account.email) { + account.email = hydratedEmail; + storageChanged = true; } + tokenAccountId = extractAccountId(refreshResult.access); + if ( + tokenAccountId && + shouldUpdateAccountIdFromToken( + account.accountIdSource, + account.accountId, + ) && + tokenAccountId !== account.accountId + ) { + account.accountId = tokenAccountId; + account.accountIdSource = "token"; + storageChanged = true; + } + } - try { - const requestAccountId = - resolveRequestAccountId( - account.accountId, - account.accountIdSource, - tokenAccountId, - ) ?? - tokenAccountId ?? - account.accountId; + if (!accessToken) { + throw new Error("Missing access token after refresh"); + } - if (!requestAccountId) { - throw new Error("Missing accountId for quota probe"); - } + if (deepProbe) { + ok += 1; + const detail = tokenAccountId + ? `${authDetail} (id:${tokenAccountId.slice(-6)})` + : authDetail; + console.log(`[${i + 1}/${total}] ${label}: ${detail}`); + continue; + } - const snapshot = await fetchCodexQuotaSnapshot({ - accountId: requestAccountId, - accessToken, - }); - ok += 1; - console.log( - `[${i + 1}/${total}] ${label}: ${formatCodexQuotaLine(snapshot)}`, - ); - } catch (error) { - errors += 1; - const message = error instanceof Error ? error.message : String(error); - console.log( - `[${i + 1}/${total}] ${label}: ERROR (${message.slice(0, 160)})`, - ); + try { + const requestAccountId = + resolveRequestAccountId( + account.accountId, + account.accountIdSource, + tokenAccountId, + ) ?? + tokenAccountId ?? + account.accountId; + + if (!requestAccountId) { + throw new Error("Missing accountId for quota probe"); } + + const snapshot = await fetchCodexQuotaSnapshot({ + accountId: requestAccountId, + accessToken, + }); + ok += 1; + console.log( + `[${i + 1}/${total}] ${label}: ${formatCodexQuotaLine(snapshot)}`, + ); } catch (error) { errors += 1; - const message = error instanceof Error ? error.message : String(error); - console.log(`[${i + 1}/${total}] ${label}: ERROR (${message.slice(0, 120)})`); + const message = + error instanceof Error ? error.message : String(error); + console.log( + `[${i + 1}/${total}] ${label}: ERROR (${message.slice(0, 160)})`, + ); } - } - - if (removeFromActive.size > 0) { - workingStorage.accounts = workingStorage.accounts.filter( - (account) => !removeFromActive.has(account.refreshToken), - ); - clampActiveIndices(workingStorage); - storageChanged = true; - } - - if (storageChanged) { - await saveAccounts(workingStorage); - invalidateAccountManagerCache(); - } - if (flaggedChanged) { - await saveFlaggedAccounts(flaggedStorage); - } - - console.log(""); - console.log(`Results: ${ok} ok, ${errors} error, ${disabled} disabled`); - if (removeFromActive.size > 0) { + } catch (error) { + errors += 1; + const message = + error instanceof Error ? error.message : String(error); console.log( - `Moved ${removeFromActive.size} account(s) to flagged pool (invalid refresh token).`, + `[${i + 1}/${total}] ${label}: ERROR (${message.slice(0, 120)})`, ); } - console.log(""); - }; - - const verifyFlaggedAccounts = async (): Promise => { - const flaggedStorage = await loadFlaggedAccounts(); - if (flaggedStorage.accounts.length === 0) { - console.log("\nNo flagged accounts to verify.\n"); - return; - } + } - console.log("\nVerifying flagged accounts...\n"); - const remaining: FlaggedAccountMetadataV1[] = []; - const restored: TokenSuccessWithAccount[] = []; + if (removeFromActive.size > 0) { + workingStorage.accounts = workingStorage.accounts.filter( + (account) => !removeFromActive.has(account.refreshToken), + ); + clampActiveIndices(workingStorage); + storageChanged = true; + } - for (let i = 0; i < flaggedStorage.accounts.length; i += 1) { - const flagged = flaggedStorage.accounts[i]; - if (!flagged) continue; - const label = flagged.email ?? flagged.accountLabel ?? `Flagged ${i + 1}`; - try { - const cached = await lookupCodexCliTokensByEmail(flagged.email); - const now = Date.now(); - if ( - cached && - typeof cached.expiresAt === "number" && - Number.isFinite(cached.expiresAt) && - cached.expiresAt > now - ) { - const refreshToken = - typeof cached.refreshToken === "string" && cached.refreshToken.trim() - ? cached.refreshToken.trim() - : flagged.refreshToken; - const resolved = resolveAccountSelection({ - type: "success", - access: cached.accessToken, - refresh: refreshToken, - expires: cached.expiresAt, - multiAccount: true, - }); - if (!resolved.accountIdOverride && flagged.accountId) { - resolved.accountIdOverride = flagged.accountId; - resolved.accountIdSource = flagged.accountIdSource ?? "manual"; - } - if (!resolved.accountLabel && flagged.accountLabel) { - resolved.accountLabel = flagged.accountLabel; - } - restored.push(resolved); - console.log( - `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: RESTORED (Codex CLI cache)`, - ); - continue; - } + if (storageChanged) { + await saveAccounts(workingStorage); + invalidateAccountManagerCache(); + } + if (flaggedChanged) { + await saveFlaggedAccounts(flaggedStorage); + } - const refreshResult = await queuedRefresh(flagged.refreshToken); - if (refreshResult.type !== "success") { - console.log( - `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: STILL FLAGGED (${refreshResult.message ?? refreshResult.reason ?? "refresh failed"})`, - ); - remaining.push(flagged); - continue; - } + console.log(""); + console.log( + `Results: ${ok} ok, ${errors} error, ${disabled} disabled`, + ); + if (removeFromActive.size > 0) { + console.log( + `Moved ${removeFromActive.size} account(s) to flagged pool (invalid refresh token).`, + ); + } + console.log(""); + }; + + const verifyFlaggedAccounts = async (): Promise => { + const flaggedStorage = await loadFlaggedAccounts(); + if (flaggedStorage.accounts.length === 0) { + console.log("\nNo flagged accounts to verify.\n"); + return; + } - const resolved = resolveAccountSelection(refreshResult); + console.log("\nVerifying flagged accounts...\n"); + const remaining: FlaggedAccountMetadataV1[] = []; + const restored: TokenSuccessWithAccount[] = []; + + for (let i = 0; i < flaggedStorage.accounts.length; i += 1) { + const flagged = flaggedStorage.accounts[i]; + if (!flagged) continue; + const label = + flagged.email ?? flagged.accountLabel ?? `Flagged ${i + 1}`; + try { + const cached = await lookupCodexCliTokensByEmail( + flagged.email, + ); + const now = Date.now(); + if ( + cached && + typeof cached.expiresAt === "number" && + Number.isFinite(cached.expiresAt) && + cached.expiresAt > now + ) { + const refreshToken = + typeof cached.refreshToken === "string" && + cached.refreshToken.trim() + ? cached.refreshToken.trim() + : flagged.refreshToken; + const resolved = resolveAccountSelection({ + type: "success", + access: cached.accessToken, + refresh: refreshToken, + expires: cached.expiresAt, + multiAccount: true, + }); if (!resolved.accountIdOverride && flagged.accountId) { resolved.accountIdOverride = flagged.accountId; - resolved.accountIdSource = flagged.accountIdSource ?? "manual"; + resolved.accountIdSource = + flagged.accountIdSource ?? "manual"; } if (!resolved.accountLabel && flagged.accountLabel) { resolved.accountLabel = flagged.accountLabel; } restored.push(resolved); - console.log(`[${i + 1}/${flaggedStorage.accounts.length}] ${label}: RESTORED`); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); console.log( - `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: ERROR (${message.slice(0, 120)})`, + `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: RESTORED (Codex CLI cache)`, ); - remaining.push({ - ...flagged, - lastError: message, - }); + continue; } - } - if (restored.length > 0) { - await persistAccountPool(restored, false); - invalidateAccountManagerCache(); + const refreshResult = await queuedRefresh( + flagged.refreshToken, + ); + if (refreshResult.type !== "success") { + console.log( + `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: STILL FLAGGED (${refreshResult.message ?? refreshResult.reason ?? "refresh failed"})`, + ); + remaining.push(flagged); + continue; + } + + const resolved = resolveAccountSelection(refreshResult); + if (!resolved.accountIdOverride && flagged.accountId) { + resolved.accountIdOverride = flagged.accountId; + resolved.accountIdSource = + flagged.accountIdSource ?? "manual"; + } + if (!resolved.accountLabel && flagged.accountLabel) { + resolved.accountLabel = flagged.accountLabel; + } + restored.push(resolved); + console.log( + `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: RESTORED`, + ); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + console.log( + `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: ERROR (${message.slice(0, 120)})`, + ); + remaining.push({ + ...flagged, + lastError: message, + }); } + } - await saveFlaggedAccounts({ - version: 1, - accounts: remaining, - }); + if (restored.length > 0) { + await persistAccountPool(restored, false); + invalidateAccountManagerCache(); + } - console.log(""); - console.log(`Results: ${restored.length} restored, ${remaining.length} still flagged`); - console.log(""); - }; + await saveFlaggedAccounts({ + version: 1, + accounts: remaining, + }); - if (!explicitLoginMode) { - while (true) { - const loadedStorage = await hydrateEmails(await loadAccounts()); - const workingStorage = loadedStorage - ? { + console.log(""); + console.log( + `Results: ${restored.length} restored, ${remaining.length} still flagged`, + ); + console.log(""); + }; + + if (!explicitLoginMode) { + while (true) { + const loadedStorage = await hydrateEmails(await loadAccounts()); + const workingStorage = loadedStorage + ? { ...loadedStorage, - accounts: loadedStorage.accounts.map((account) => ({ ...account })), + accounts: loadedStorage.accounts.map((account) => ({ + ...account, + })), activeIndexByFamily: loadedStorage.activeIndexByFamily ? { ...loadedStorage.activeIndexByFamily } : {}, } - : { version: 3 as const, accounts: [], activeIndex: 0, activeIndexByFamily: {} }; - const flaggedStorage = await loadFlaggedAccounts(); + : { + version: 3 as const, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }; + const flaggedStorage = await loadFlaggedAccounts(); - if (workingStorage.accounts.length === 0 && flaggedStorage.accounts.length === 0) { - break; - } + if ( + workingStorage.accounts.length === 0 && + flaggedStorage.accounts.length === 0 + ) { + break; + } - const now = Date.now(); - const activeIndex = resolveActiveIndex(workingStorage, "codex"); - const existingAccounts = workingStorage.accounts.map((account, index) => { - let status: "active" | "ok" | "rate-limited" | "cooldown" | "disabled"; + const now = Date.now(); + const activeIndex = resolveActiveIndex(workingStorage, "codex"); + const existingAccounts = workingStorage.accounts.map( + (account, index) => { + let status: + | "active" + | "ok" + | "rate-limited" + | "cooldown" + | "disabled"; if (account.enabled === false) { status = "disabled"; } else if ( @@ -3275,219 +3767,155 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { isCurrentAccount: index === activeIndex, enabled: account.enabled !== false, }; - }); - - const menuResult = await promptLoginMode(existingAccounts, { - flaggedCount: flaggedStorage.accounts.length, - }); - - if (menuResult.mode === "cancel") { - return { - url: "", - instructions: "Authentication cancelled", - method: "auto", - callback: () => - Promise.resolve({ - type: "failed" as const, - }), - }; - } + }, + ); - if (menuResult.mode === "check") { - await runAccountCheck(false); - continue; - } - if (menuResult.mode === "deep-check") { - await runAccountCheck(true); - continue; - } - if (menuResult.mode === "verify-flagged") { - await verifyFlaggedAccounts(); - continue; - } + const menuResult = await promptLoginMode(existingAccounts, { + flaggedCount: flaggedStorage.accounts.length, + }); - if (menuResult.mode === "manage") { - if (typeof menuResult.deleteAccountIndex === "number") { - const target = workingStorage.accounts[menuResult.deleteAccountIndex]; - if (target) { - workingStorage.accounts.splice(menuResult.deleteAccountIndex, 1); - clampActiveIndices(workingStorage); - await saveAccounts(workingStorage); - await saveFlaggedAccounts({ - version: 1, - accounts: flaggedStorage.accounts.filter( - (flagged) => flagged.refreshToken !== target.refreshToken, - ), - }); - invalidateAccountManagerCache(); - console.log(`\nDeleted ${target.email ?? `Account ${menuResult.deleteAccountIndex + 1}`}.\n`); - } - continue; - } + if (menuResult.mode === "cancel") { + return { + url: "", + instructions: "Authentication cancelled", + method: "auto", + callback: () => + Promise.resolve({ + type: "failed" as const, + }), + }; + } - if (typeof menuResult.toggleAccountIndex === "number") { - const target = workingStorage.accounts[menuResult.toggleAccountIndex]; - if (target) { - target.enabled = target.enabled === false ? true : false; - await saveAccounts(workingStorage); - invalidateAccountManagerCache(); - console.log( - `\n${target.email ?? `Account ${menuResult.toggleAccountIndex + 1}`} ${target.enabled === false ? "disabled" : "enabled"}.\n`, - ); - } - continue; - } + if (menuResult.mode === "check") { + await runAccountCheck(false); + continue; + } + if (menuResult.mode === "deep-check") { + await runAccountCheck(true); + continue; + } + if (menuResult.mode === "verify-flagged") { + await verifyFlaggedAccounts(); + continue; + } - if (typeof menuResult.refreshAccountIndex === "number") { - refreshAccountIndex = menuResult.refreshAccountIndex; - startFresh = false; - break; + if (menuResult.mode === "manage") { + if (typeof menuResult.deleteAccountIndex === "number") { + const target = + workingStorage.accounts[menuResult.deleteAccountIndex]; + if (target) { + workingStorage.accounts.splice( + menuResult.deleteAccountIndex, + 1, + ); + clampActiveIndices(workingStorage); + await saveAccounts(workingStorage); + await saveFlaggedAccounts({ + version: 1, + accounts: flaggedStorage.accounts.filter( + (flagged) => + flagged.refreshToken !== target.refreshToken, + ), + }); + invalidateAccountManagerCache(); + console.log( + `\nDeleted ${target.email ?? `Account ${menuResult.deleteAccountIndex + 1}`}.\n`, + ); } - continue; } - if (menuResult.mode === "fresh") { - startFresh = true; - if (menuResult.deleteAll) { - await clearAccounts(); - await clearFlaggedAccounts(); + if (typeof menuResult.toggleAccountIndex === "number") { + const target = + workingStorage.accounts[menuResult.toggleAccountIndex]; + if (target) { + target.enabled = target.enabled === false ? true : false; + await saveAccounts(workingStorage); invalidateAccountManagerCache(); console.log( - "\nCleared saved accounts from active storage. Recovery snapshots remain available. Starting fresh.\n", + `\n${target.email ?? `Account ${menuResult.toggleAccountIndex + 1}`} ${target.enabled === false ? "disabled" : "enabled"}.\n`, ); } + continue; + } + + if (typeof menuResult.refreshAccountIndex === "number") { + refreshAccountIndex = menuResult.refreshAccountIndex; + startFresh = false; break; } - startFresh = false; - break; + continue; } - } - - const latestStorage = await loadAccounts(); - const existingCount = latestStorage?.accounts.length ?? 0; - const requestedCount = Number.parseInt(inputs?.accountCount ?? "1", 10); - const normalizedRequested = Number.isFinite(requestedCount) ? requestedCount : 1; - const availableSlots = - refreshAccountIndex !== undefined - ? 1 - : startFresh - ? ACCOUNT_LIMITS.MAX_ACCOUNTS - : ACCOUNT_LIMITS.MAX_ACCOUNTS - existingCount; - - if (availableSlots <= 0) { - return { - url: "", - instructions: "Account limit reached. Remove an account or start fresh.", - method: "auto", - callback: () => - Promise.resolve({ - type: "failed" as const, - }), - }; - } - - let targetCount = Math.max(1, Math.min(normalizedRequested, availableSlots)); - if (refreshAccountIndex !== undefined) { - targetCount = 1; - } - if (useManualMode) { - targetCount = 1; - } - if (useManualMode) { - const { pkce, state, url } = await createAuthorizationFlow(); - return buildManualOAuthFlow(pkce, url, state, async (tokens) => { - try { - await persistAccountPool([tokens], startFresh); + if (menuResult.mode === "fresh") { + startFresh = true; + if (menuResult.deleteAll) { + await clearAccounts(); + await clearFlaggedAccounts(); invalidateAccountManagerCache(); - } catch (err) { - const storagePath = getStoragePath(); - const errorCode = (err as NodeJS.ErrnoException)?.code || "UNKNOWN"; - const hint = - err instanceof StorageError - ? err.hint - : formatStorageErrorHint(err, storagePath); - logError( - `[${PLUGIN_NAME}] Failed to persist account: [${errorCode}] ${(err as Error)?.message ?? String(err)}`, + console.log( + "\nCleared saved accounts from active storage. Recovery snapshots remain available. Starting fresh.\n", ); - await showToast(hint, "error", { - title: "Account Persistence Failed", - duration: 10000, - }); - } - }); - } - - const explicitCountProvided = - typeof inputs?.accountCount === "string" && inputs.accountCount.trim().length > 0; - - while (accounts.length < targetCount) { - logInfo(`=== OpenAI OAuth (Account ${accounts.length + 1}) ===`); - const forceNewLogin = accounts.length > 0 || refreshAccountIndex !== undefined; - const result = await runOAuthFlow(forceNewLogin); - - let resolved: TokenSuccessWithAccount | null = null; - if (result.type === "success") { - resolved = resolveAccountSelection(result); - const email = extractAccountEmail(resolved.access, resolved.idToken); - const accountId = resolved.accountIdOverride ?? extractAccountId(resolved.access); - const label = resolved.accountLabel ?? email ?? accountId ?? "Unknown account"; - logInfo(`Authenticated as: ${label}`); - - const isDuplicate = - findMatchingAccountIndex( - accounts.map((account) => ({ - accountId: - account.accountIdOverride ?? extractAccountId(account.access), - email: sanitizeEmail( - extractAccountEmail(account.access, account.idToken), - ), - refreshToken: account.refresh, - })), - { - accountId, - email: sanitizeEmail(email), - refreshToken: resolved.refresh, - }, - { - allowUniqueAccountIdFallbackWithoutEmail: true, - }, - ) !== undefined; - - if (isDuplicate) { - logWarn(`WARNING: duplicate account login detected (${label}). Existing entry will be updated.`); } - } - - if (result.type === "failed") { - if (accounts.length === 0) { - return { - url: "", - instructions: "Authentication failed.", - method: "auto", - callback: () => Promise.resolve(result), - }; - } - logWarn(`[${PLUGIN_NAME}] Skipping failed account ${accounts.length + 1}`); break; } - if (!resolved) { - continue; - } + startFresh = false; + break; + } + } + + const latestStorage = await loadAccounts(); + const existingCount = latestStorage?.accounts.length ?? 0; + const requestedCount = Number.parseInt( + inputs?.accountCount ?? "1", + 10, + ); + const normalizedRequested = Number.isFinite(requestedCount) + ? requestedCount + : 1; + const availableSlots = + refreshAccountIndex !== undefined + ? 1 + : startFresh + ? ACCOUNT_LIMITS.MAX_ACCOUNTS + : ACCOUNT_LIMITS.MAX_ACCOUNTS - existingCount; + + if (availableSlots <= 0) { + return { + url: "", + instructions: + "Account limit reached. Remove an account or start fresh.", + method: "auto", + callback: () => + Promise.resolve({ + type: "failed" as const, + }), + }; + } - accounts.push(resolved); - await showToast(`Account ${accounts.length} authenticated`, "success"); + let targetCount = Math.max( + 1, + Math.min(normalizedRequested, availableSlots), + ); + if (refreshAccountIndex !== undefined) { + targetCount = 1; + } + if (useManualMode) { + targetCount = 1; + } + if (useManualMode) { + const { pkce, state, url } = await createAuthorizationFlow(); + return buildManualOAuthFlow(pkce, url, state, async (tokens) => { try { - const isFirstAccount = accounts.length === 1; - await persistAccountPool([resolved], isFirstAccount && startFresh); + await persistAccountPool([tokens], startFresh); invalidateAccountManagerCache(); } catch (err) { const storagePath = getStoragePath(); - const errorCode = (err as NodeJS.ErrnoException)?.code || "UNKNOWN"; + const errorCode = + (err as NodeJS.ErrnoException)?.code || "UNKNOWN"; const hint = err instanceof StorageError ? err.hint @@ -3500,106 +3928,217 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { duration: 10000, }); } + }); + } - if (accounts.length >= ACCOUNT_LIMITS.MAX_ACCOUNTS) { - break; + const explicitCountProvided = + typeof inputs?.accountCount === "string" && + inputs.accountCount.trim().length > 0; + + while (accounts.length < targetCount) { + logInfo(`=== OpenAI OAuth (Account ${accounts.length + 1}) ===`); + const forceNewLogin = + accounts.length > 0 || refreshAccountIndex !== undefined; + const result = await runOAuthFlow(forceNewLogin); + + let resolved: TokenSuccessWithAccount | null = null; + if (result.type === "success") { + resolved = resolveAccountSelection(result); + const email = extractAccountEmail( + resolved.access, + resolved.idToken, + ); + const accountId = + resolved.accountIdOverride ?? + extractAccountId(resolved.access); + const label = + resolved.accountLabel ?? + email ?? + accountId ?? + "Unknown account"; + logInfo(`Authenticated as: ${label}`); + + const isDuplicate = + findMatchingAccountIndex( + accounts.map((account) => ({ + accountId: + account.accountIdOverride ?? + extractAccountId(account.access), + email: sanitizeEmail( + extractAccountEmail(account.access, account.idToken), + ), + refreshToken: account.refresh, + })), + { + accountId, + email: sanitizeEmail(email), + refreshToken: resolved.refresh, + }, + { + allowUniqueAccountIdFallbackWithoutEmail: true, + }, + ) !== undefined; + + if (isDuplicate) { + logWarn( + `WARNING: duplicate account login detected (${label}). Existing entry will be updated.`, + ); } + } - if ( - !explicitCountProvided && - refreshAccountIndex === undefined && - accounts.length < availableSlots && - accounts.length >= targetCount - ) { - const addMore = await promptAddAnotherAccount(accounts.length); - if (addMore) { - targetCount = Math.min(targetCount + 1, availableSlots); - continue; - } - break; + if (result.type === "failed") { + if (accounts.length === 0) { + return { + url: "", + instructions: "Authentication failed.", + method: "auto", + callback: () => Promise.resolve(result), + }; } + logWarn( + `[${PLUGIN_NAME}] Skipping failed account ${accounts.length + 1}`, + ); + break; } - const primary = accounts[0]; - if (!primary) { - return { - url: "", - instructions: "Authentication cancelled", - method: "auto", - callback: () => - Promise.resolve({ - type: "failed" as const, - }), - }; + if (!resolved) { + continue; } - let actualAccountCount = accounts.length; + accounts.push(resolved); + await showToast( + `Account ${accounts.length} authenticated`, + "success", + ); + try { - const finalStorage = await loadAccounts(); - if (finalStorage) { - actualAccountCount = finalStorage.accounts.length; - } + const isFirstAccount = accounts.length === 1; + await persistAccountPool( + [resolved], + isFirstAccount && startFresh, + ); + invalidateAccountManagerCache(); } catch (err) { - logWarn( - `[${PLUGIN_NAME}] Failed to load final account count: ${(err as Error)?.message ?? String(err)}`, + const storagePath = getStoragePath(); + const errorCode = + (err as NodeJS.ErrnoException)?.code || "UNKNOWN"; + const hint = + err instanceof StorageError + ? err.hint + : formatStorageErrorHint(err, storagePath); + logError( + `[${PLUGIN_NAME}] Failed to persist account: [${errorCode}] ${(err as Error)?.message ?? String(err)}`, ); + await showToast(hint, "error", { + title: "Account Persistence Failed", + duration: 10000, + }); + } + + if (accounts.length >= ACCOUNT_LIMITS.MAX_ACCOUNTS) { + break; + } + + if ( + !explicitCountProvided && + refreshAccountIndex === undefined && + accounts.length < availableSlots && + accounts.length >= targetCount + ) { + const addMore = await promptAddAnotherAccount(accounts.length); + if (addMore) { + targetCount = Math.min(targetCount + 1, availableSlots); + continue; + } + break; } + } + const primary = accounts[0]; + if (!primary) { return { url: "", - instructions: `Multi-account setup complete (${actualAccountCount} account(s)).`, + instructions: "Authentication cancelled", method: "auto", - callback: () => Promise.resolve(primary), + callback: () => + Promise.resolve({ + type: "failed" as const, + }), }; - }, + } + + let actualAccountCount = accounts.length; + try { + const finalStorage = await loadAccounts(); + if (finalStorage) { + actualAccountCount = finalStorage.accounts.length; + } + } catch (err) { + logWarn( + `[${PLUGIN_NAME}] Failed to load final account count: ${(err as Error)?.message ?? String(err)}`, + ); + } + + return { + url: "", + instructions: `Multi-account setup complete (${actualAccountCount} account(s)).`, + method: "auto", + callback: () => Promise.resolve(primary), + }; }, + }, { label: AUTH_LABELS.OAUTH_MANUAL, type: "oauth" as const, - authorize: async () => { - // Initialize storage path for manual OAuth flow - // Must happen BEFORE persistAccountPool to ensure correct storage location - const manualPluginConfig = loadPluginConfig(); - applyUiRuntimeFromConfig(manualPluginConfig); - applyAccountStorageScope(manualPluginConfig); - - const { pkce, state, url } = await createAuthorizationFlow(); - return buildManualOAuthFlow(pkce, url, state, async (tokens) => { - try { - await persistAccountPool([tokens], false); - } catch (err) { - const storagePath = getStoragePath(); - const errorCode = (err as NodeJS.ErrnoException)?.code || "UNKNOWN"; - const hint = err instanceof StorageError ? err.hint : formatStorageErrorHint(err, storagePath); - logError(`[${PLUGIN_NAME}] Failed to persist account: [${errorCode}] ${(err as Error)?.message ?? String(err)}`); - await showToast( - hint, - "error", - { title: "Account Persistence Failed", duration: 10000 }, - ); - } - }); - }, - }, - ], - }, - tool: { - edit: createHashlineEditTool(), - // Legacy runtime v1.2.x exposes apply_patch (not edit) to the model. - // Register the same hashline-capable implementation under both names. - apply_patch: createHashlineEditTool(), - hashline_read: createHashlineReadTool(), - "codex-list": tool({ - description: - "List all Codex OAuth accounts and the current active index.", - args: {}, - async execute() { + authorize: async () => { + // Initialize storage path for manual OAuth flow + // Must happen BEFORE persistAccountPool to ensure correct storage location + const manualPluginConfig = loadPluginConfig(); + applyUiRuntimeFromConfig(manualPluginConfig); + applyAccountStorageScope(manualPluginConfig); + + const { pkce, state, url } = await createAuthorizationFlow(); + return buildManualOAuthFlow(pkce, url, state, async (tokens) => { + try { + await persistAccountPool([tokens], false); + } catch (err) { + const storagePath = getStoragePath(); + const errorCode = + (err as NodeJS.ErrnoException)?.code || "UNKNOWN"; + const hint = + err instanceof StorageError + ? err.hint + : formatStorageErrorHint(err, storagePath); + logError( + `[${PLUGIN_NAME}] Failed to persist account: [${errorCode}] ${(err as Error)?.message ?? String(err)}`, + ); + await showToast(hint, "error", { + title: "Account Persistence Failed", + duration: 10000, + }); + } + }); + }, + }, + ], + }, + tool: { + edit: createHashlineEditTool(), + // Legacy runtime v1.2.x exposes apply_patch (not edit) to the model. + // Register the same hashline-capable implementation under both names. + apply_patch: createHashlineEditTool(), + hashline_read: createHashlineReadTool(), + "codex-list": tool({ + description: + "List all Codex OAuth accounts and the current active index.", + args: {}, + async execute() { const ui = resolveUiRuntime(); - const storage = await loadAccounts(); - const storePath = getStoragePath(); + const storage = await loadAccounts(); + const storePath = getStoragePath(); - if (!storage || storage.accounts.length === 0) { + if (!storage || storage.accounts.length === 0) { if (ui.v2Enabled) { return [ ...formatUiHeader(ui, "Codex accounts"), @@ -3609,15 +4148,15 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { formatUiKeyValue(ui, "Storage", storePath, "muted"), ].join("\n"); } - return [ - "No Codex accounts configured.", - "", - "Add accounts:", - " codex login", - "", - `Storage: ${storePath}`, - ].join("\n"); - } + return [ + "No Codex accounts configured.", + "", + "Add accounts:", + " codex login", + "", + `Storage: ${storePath}`, + ].join("\n"); + } const now = Date.now(); const activeIndex = resolveActiveIndex(storage, "codex"); @@ -3633,10 +4172,13 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { storage.accounts.forEach((account, index) => { const label = formatAccountLabel(account, index); const badges: string[] = []; - if (index === activeIndex) badges.push(formatUiBadge(ui, "current", "accent")); - if (account.enabled === false) badges.push(formatUiBadge(ui, "disabled", "danger")); + if (index === activeIndex) + badges.push(formatUiBadge(ui, "current", "accent")); + if (account.enabled === false) + badges.push(formatUiBadge(ui, "disabled", "danger")); const rateLimit = formatRateLimitEntry(account, now); - if (rateLimit) badges.push(formatUiBadge(ui, "rate-limited", "warning")); + if (rateLimit) + badges.push(formatUiBadge(ui, "rate-limited", "warning")); if ( typeof account.coolingDownUntil === "number" && account.coolingDownUntil > now @@ -3647,21 +4189,30 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { badges.push(formatUiBadge(ui, "ok", "success")); } - lines.push(formatUiItem(ui, `${index + 1}. ${label} ${badges.join(" ")}`.trim())); + lines.push( + formatUiItem( + ui, + `${index + 1}. ${label} ${badges.join(" ")}`.trim(), + ), + ); if (rateLimit) { - lines.push(` ${paintUiText(ui, `rate limit: ${rateLimit}`, "muted")}`); + lines.push( + ` ${paintUiText(ui, `rate limit: ${rateLimit}`, "muted")}`, + ); } }); lines.push(""); lines.push(...formatUiSection(ui, "Commands")); lines.push(formatUiItem(ui, "Add account: codex login", "accent")); - lines.push(formatUiItem(ui, "Switch account: codex-switch ")); + lines.push( + formatUiItem(ui, "Switch account: codex-switch "), + ); lines.push(formatUiItem(ui, "Detailed status: codex-status")); lines.push(formatUiItem(ui, "Health check: codex-health")); return lines.join("\n"); } - + const listTableOptions: TableOptions = { columns: [ { header: "#", width: 3 }, @@ -3669,56 +4220,59 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { { header: "Status", width: 20 }, ], }; - + const lines: string[] = [ `Codex Accounts (${storage.accounts.length}):`, "", ...buildTableHeader(listTableOptions), ]; - storage.accounts.forEach((account, index) => { - const label = formatAccountLabel(account, index); - const statuses: string[] = []; - const rateLimit = formatRateLimitEntry( - account, - now, - ); - if (index === activeIndex) statuses.push("active"); - if (rateLimit) statuses.push("rate-limited"); - if ( - typeof account.coolingDownUntil === - "number" && - account.coolingDownUntil > now - ) { - statuses.push("cooldown"); - } - const statusText = statuses.length > 0 ? statuses.join(", ") : "ok"; - lines.push(buildTableRow([String(index + 1), label, statusText], listTableOptions)); - }); - - lines.push(""); - lines.push(`Storage: ${storePath}`); - lines.push(""); - lines.push("Commands:"); - lines.push(" - Add account: codex login"); - lines.push(" - Switch account: codex-switch"); - lines.push(" - Status details: codex-status"); - lines.push(" - Health check: codex-health"); - - return lines.join("\n"); - }, - }), - "codex-switch": tool({ - description: "Switch active Codex account by index (1-based).", - args: { - index: tool.schema.number().describe( - "Account number to switch to (1-based, e.g., 1 for first account)", - ), - }, - async execute({ index }) { + storage.accounts.forEach((account, index) => { + const label = formatAccountLabel(account, index); + const statuses: string[] = []; + const rateLimit = formatRateLimitEntry(account, now); + if (index === activeIndex) statuses.push("active"); + if (rateLimit) statuses.push("rate-limited"); + if ( + typeof account.coolingDownUntil === "number" && + account.coolingDownUntil > now + ) { + statuses.push("cooldown"); + } + const statusText = statuses.length > 0 ? statuses.join(", ") : "ok"; + lines.push( + buildTableRow( + [String(index + 1), label, statusText], + listTableOptions, + ), + ); + }); + + lines.push(""); + lines.push(`Storage: ${storePath}`); + lines.push(""); + lines.push("Commands:"); + lines.push(" - Add account: codex login"); + lines.push(" - Switch account: codex-switch"); + lines.push(" - Status details: codex-status"); + lines.push(" - Health check: codex-health"); + + return lines.join("\n"); + }, + }), + "codex-switch": tool({ + description: "Switch active Codex account by index (1-based).", + args: { + index: tool.schema + .number() + .describe( + "Account number to switch to (1-based, e.g., 1 for first account)", + ), + }, + async execute({ index }) { const ui = resolveUiRuntime(); - const storage = await loadAccounts(); - if (!storage || storage.accounts.length === 0) { + const storage = await loadAccounts(); + if (!storage || storage.accounts.length === 0) { if (ui.v2Enabled) { return [ ...formatUiHeader(ui, "Switch account"), @@ -3727,48 +4281,63 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { formatUiItem(ui, "Run: codex login", "accent"), ].join("\n"); } - return "No Codex accounts configured. Run: codex login"; - } - - const targetIndex = Math.floor((index ?? 0) - 1); - if ( - !Number.isFinite(targetIndex) || - targetIndex < 0 || - targetIndex >= storage.accounts.length - ) { + return "No Codex accounts configured. Run: codex login"; + } + + const targetIndex = Math.floor((index ?? 0) - 1); + if ( + !Number.isFinite(targetIndex) || + targetIndex < 0 || + targetIndex >= storage.accounts.length + ) { if (ui.v2Enabled) { return [ ...formatUiHeader(ui, "Switch account"), "", formatUiItem(ui, `Invalid account number: ${index}`, "danger"), - formatUiKeyValue(ui, "Valid range", `1-${storage.accounts.length}`, "muted"), + formatUiKeyValue( + ui, + "Valid range", + `1-${storage.accounts.length}`, + "muted", + ), ].join("\n"); } - return `Invalid account number: ${index}\n\nValid range: 1-${storage.accounts.length}`; - } + return `Invalid account number: ${index}\n\nValid range: 1-${storage.accounts.length}`; + } - const now = Date.now(); - const account = storage.accounts[targetIndex]; - if (account) { - account.lastUsed = now; - account.lastSwitchReason = "rotation"; - } + const now = Date.now(); + const account = storage.accounts[targetIndex]; + if (account) { + account.lastUsed = now; + account.lastSwitchReason = "rotation"; + } storage.activeIndex = targetIndex; storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; for (const family of MODEL_FAMILIES) { - storage.activeIndexByFamily[family] = targetIndex; + storage.activeIndexByFamily[family] = targetIndex; } try { await saveAccounts(storage); } catch (saveError) { - logWarn("Failed to save account switch", { error: String(saveError) }); + logWarn("Failed to save account switch", { + error: String(saveError), + }); if (ui.v2Enabled) { return [ ...formatUiHeader(ui, "Switch account"), "", - formatUiItem(ui, `Switched to ${formatAccountLabel(account, targetIndex)}`, "warning"), - formatUiItem(ui, "Failed to persist change. It may be lost on restart.", "danger"), + formatUiItem( + ui, + `Switched to ${formatAccountLabel(account, targetIndex)}`, + "warning", + ), + formatUiItem( + ui, + "Failed to persist change. It may be lost on restart.", + "danger", + ), ].join("\n"); } return `Switched to ${formatAccountLabel(account, targetIndex)} but failed to persist. Changes may be lost on restart.`; @@ -3778,17 +4347,21 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { await reloadAccountManagerFromDisk(); } - const label = formatAccountLabel(account, targetIndex); + const label = formatAccountLabel(account, targetIndex); if (ui.v2Enabled) { return [ ...formatUiHeader(ui, "Switch account"), "", - formatUiItem(ui, `${getStatusMarker(ui, "ok")} Switched to ${label}`, "success"), + formatUiItem( + ui, + `${getStatusMarker(ui, "ok")} Switched to ${label}`, + "success", + ), ].join("\n"); } - return `Switched to account: ${label}`; - }, - }), + return `Switched to account: ${label}`; + }, + }), "codex-status": tool({ description: "Show detailed status of Codex accounts and rate limits.", args: {}, @@ -3807,215 +4380,363 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { return "No Codex accounts configured. Run: codex login"; } - const now = Date.now(); - const activeIndex = resolveActiveIndex(storage, "codex"); - if (ui.v2Enabled) { + const now = Date.now(); + const activeIndex = resolveActiveIndex(storage, "codex"); + if (ui.v2Enabled) { + const lines: string[] = [ + ...formatUiHeader(ui, "Account status"), + formatUiKeyValue(ui, "Total", String(storage.accounts.length)), + "", + ...formatUiSection(ui, "Accounts"), + ]; + + storage.accounts.forEach((account, index) => { + const label = formatAccountLabel(account, index); + const badges: string[] = []; + if (index === activeIndex) + badges.push(formatUiBadge(ui, "active", "accent")); + if (account.enabled === false) + badges.push(formatUiBadge(ui, "disabled", "danger")); + const rateLimit = formatRateLimitEntry(account, now) ?? "none"; + const cooldown = formatCooldown(account, now) ?? "none"; + if (rateLimit !== "none") + badges.push(formatUiBadge(ui, "rate-limited", "warning")); + if (cooldown !== "none") + badges.push(formatUiBadge(ui, "cooldown", "warning")); + if (badges.length === 0) + badges.push(formatUiBadge(ui, "ok", "success")); + + lines.push( + formatUiItem( + ui, + `${index + 1}. ${label} ${badges.join(" ")}`.trim(), + ), + ); + lines.push( + ` ${formatUiKeyValue(ui, "rate limit", rateLimit, rateLimit === "none" ? "muted" : "warning")}`, + ); + lines.push( + ` ${formatUiKeyValue(ui, "cooldown", cooldown, cooldown === "none" ? "muted" : "warning")}`, + ); + }); + + lines.push(""); + lines.push(...formatUiSection(ui, "Active index by model family")); + for (const family of MODEL_FAMILIES) { + const idx = storage.activeIndexByFamily?.[family]; + const familyIndexLabel = + typeof idx === "number" && Number.isFinite(idx) + ? String(idx + 1) + : "-"; + lines.push(formatUiItem(ui, `${family}: ${familyIndexLabel}`)); + } + + lines.push(""); + lines.push( + ...formatUiSection( + ui, + "Rate limits by model family (per account)", + ), + ); + storage.accounts.forEach((account, index) => { + const statuses = MODEL_FAMILIES.map((family) => { + const resetAt = getRateLimitResetTimeForFamily( + account, + now, + family, + ); + if (typeof resetAt !== "number") return `${family}=ok`; + return `${family}=${formatWaitTime(resetAt - now)}`; + }); + lines.push( + formatUiItem( + ui, + `Account ${index + 1}: ${statuses.join(" | ")}`, + ), + ); + }); + + return lines.join("\n"); + } + + const statusTableOptions: TableOptions = { + columns: [ + { header: "#", width: 3 }, + { header: "Label", width: 42 }, + { header: "Active", width: 6 }, + { header: "Rate Limit", width: 16 }, + { header: "Cooldown", width: 16 }, + { header: "Last Used", width: 16 }, + ], + }; + const lines: string[] = [ - ...formatUiHeader(ui, "Account status"), - formatUiKeyValue(ui, "Total", String(storage.accounts.length)), + `Account Status (${storage.accounts.length} total):`, "", - ...formatUiSection(ui, "Accounts"), + ...buildTableHeader(statusTableOptions), ]; storage.accounts.forEach((account, index) => { const label = formatAccountLabel(account, index); - const badges: string[] = []; - if (index === activeIndex) badges.push(formatUiBadge(ui, "active", "accent")); - if (account.enabled === false) badges.push(formatUiBadge(ui, "disabled", "danger")); - const rateLimit = formatRateLimitEntry(account, now) ?? "none"; - const cooldown = formatCooldown(account, now) ?? "none"; - if (rateLimit !== "none") badges.push(formatUiBadge(ui, "rate-limited", "warning")); - if (cooldown !== "none") badges.push(formatUiBadge(ui, "cooldown", "warning")); - if (badges.length === 0) badges.push(formatUiBadge(ui, "ok", "success")); - - lines.push(formatUiItem(ui, `${index + 1}. ${label} ${badges.join(" ")}`.trim())); - lines.push(` ${formatUiKeyValue(ui, "rate limit", rateLimit, rateLimit === "none" ? "muted" : "warning")}`); - lines.push(` ${formatUiKeyValue(ui, "cooldown", cooldown, cooldown === "none" ? "muted" : "warning")}`); + const active = index === activeIndex ? "Yes" : "No"; + const rateLimit = formatRateLimitEntry(account, now) ?? "None"; + const cooldown = formatCooldown(account, now) ?? "No"; + const lastUsed = + typeof account.lastUsed === "number" && account.lastUsed > 0 + ? `${formatWaitTime(now - account.lastUsed)} ago` + : "-"; + + lines.push( + buildTableRow( + [ + String(index + 1), + label, + active, + rateLimit, + cooldown, + lastUsed, + ], + statusTableOptions, + ), + ); }); lines.push(""); - lines.push(...formatUiSection(ui, "Active index by model family")); + lines.push("Active index by model family:"); for (const family of MODEL_FAMILIES) { const idx = storage.activeIndexByFamily?.[family]; const familyIndexLabel = - typeof idx === "number" && Number.isFinite(idx) ? String(idx + 1) : "-"; - lines.push(formatUiItem(ui, `${family}: ${familyIndexLabel}`)); + typeof idx === "number" && Number.isFinite(idx) + ? String(idx + 1) + : "-"; + lines.push(` ${family}: ${familyIndexLabel}`); } lines.push(""); - lines.push(...formatUiSection(ui, "Rate limits by model family (per account)")); + lines.push("Rate limits by model family (per account):"); storage.accounts.forEach((account, index) => { const statuses = MODEL_FAMILIES.map((family) => { - const resetAt = getRateLimitResetTimeForFamily(account, now, family); + const resetAt = getRateLimitResetTimeForFamily( + account, + now, + family, + ); if (typeof resetAt !== "number") return `${family}=ok`; return `${family}=${formatWaitTime(resetAt - now)}`; }); - lines.push(formatUiItem(ui, `Account ${index + 1}: ${statuses.join(" | ")}`)); + lines.push(` Account ${index + 1}: ${statuses.join(" | ")}`); }); return lines.join("\n"); - } - - const statusTableOptions: TableOptions = { - columns: [ - { header: "#", width: 3 }, - { header: "Label", width: 42 }, - { header: "Active", width: 6 }, - { header: "Rate Limit", width: 16 }, - { header: "Cooldown", width: 16 }, - { header: "Last Used", width: 16 }, - ], - }; - - const lines: string[] = [ - `Account Status (${storage.accounts.length} total):`, - "", - ...buildTableHeader(statusTableOptions), - ]; - - storage.accounts.forEach((account, index) => { - const label = formatAccountLabel(account, index); - const active = index === activeIndex ? "Yes" : "No"; - const rateLimit = formatRateLimitEntry(account, now) ?? "None"; - const cooldown = formatCooldown(account, now) ?? "No"; - const lastUsed = - typeof account.lastUsed === "number" && account.lastUsed > 0 - ? `${formatWaitTime(now - account.lastUsed)} ago` - : "-"; - - lines.push(buildTableRow([String(index + 1), label, active, rateLimit, cooldown, lastUsed], statusTableOptions)); - }); - - lines.push(""); - lines.push("Active index by model family:"); - for (const family of MODEL_FAMILIES) { - const idx = storage.activeIndexByFamily?.[family]; - const familyIndexLabel = - typeof idx === "number" && Number.isFinite(idx) ? String(idx + 1) : "-"; - lines.push(` ${family}: ${familyIndexLabel}`); - } - - lines.push(""); - lines.push("Rate limits by model family (per account):"); - storage.accounts.forEach((account, index) => { - const statuses = MODEL_FAMILIES.map((family) => { - const resetAt = getRateLimitResetTimeForFamily(account, now, family); - if (typeof resetAt !== "number") return `${family}=ok`; - return `${family}=${formatWaitTime(resetAt - now)}`; - }); - lines.push(` Account ${index + 1}: ${statuses.join(" | ")}`); - }); + }, + }), + ...(exposeAdvancedCodexTools + ? { + "codex-metrics": tool({ + description: + "Show runtime request metrics for this plugin process.", + args: {}, + execute() { + const ui = resolveUiRuntime(); + const now = Date.now(); + const uptimeMs = Math.max(0, now - runtimeMetrics.startedAt); + const total = runtimeMetrics.totalRequests; + const successful = runtimeMetrics.successfulRequests; + const successRate = + total > 0 ? ((successful / total) * 100).toFixed(1) : "0.0"; + const avgLatencyMs = + successful > 0 + ? Math.round( + runtimeMetrics.cumulativeLatencyMs / successful, + ) + : 0; + const liveSyncSnapshot = liveAccountSync?.getSnapshot(); + const guardianStats = refreshGuardian?.getStats(); + const sessionAffinityEntries = + sessionAffinityStore?.size() ?? 0; + const lastRequest = + runtimeMetrics.lastRequestAt !== null + ? `${formatWaitTime(now - runtimeMetrics.lastRequestAt)} ago` + : "never"; + + const lines = [ + "Codex Plugin Metrics:", + "", + `Uptime: ${formatWaitTime(uptimeMs)}`, + `Total upstream requests: ${total}`, + `Successful responses: ${successful}`, + `Failed responses: ${runtimeMetrics.failedRequests}`, + `Success rate: ${successRate}%`, + `Average successful latency: ${avgLatencyMs}ms`, + `Rate-limited responses: ${runtimeMetrics.rateLimitedResponses}`, + `Server errors (5xx): ${runtimeMetrics.serverErrors}`, + `Network errors: ${runtimeMetrics.networkErrors}`, + `User aborts: ${runtimeMetrics.userAborts}`, + `Auth refresh failures: ${runtimeMetrics.authRefreshFailures}`, + `Account rotations: ${runtimeMetrics.accountRotations}`, + `Same-account retries: ${runtimeMetrics.sameAccountRetries}`, + `Stream failover attempts: ${runtimeMetrics.streamFailoverAttempts}`, + `Stream failover recoveries: ${runtimeMetrics.streamFailoverRecoveries}`, + `Stream failover cross-account recoveries: ${runtimeMetrics.streamFailoverCrossAccountRecoveries}`, + `Empty-response retries: ${runtimeMetrics.emptyResponseRetries}`, + `Session affinity entries: ${sessionAffinityEntries}`, + `Live sync: ${liveSyncSnapshot?.running ? "on" : "off"} (${liveSyncSnapshot?.reloadCount ?? 0} reloads)`, + `Refresh guardian: ${guardianStats ? "on" : "off"} (${guardianStats?.refreshed ?? 0} refreshed, ${guardianStats?.failed ?? 0} failed)`, + `Last upstream request: ${lastRequest}`, + ]; - return lines.join("\n"); - }, - }), - ...(exposeAdvancedCodexTools ? { - "codex-metrics": tool({ - description: "Show runtime request metrics for this plugin process.", - args: {}, - execute() { - const ui = resolveUiRuntime(); - const now = Date.now(); - const uptimeMs = Math.max(0, now - runtimeMetrics.startedAt); - const total = runtimeMetrics.totalRequests; - const successful = runtimeMetrics.successfulRequests; - const successRate = total > 0 ? ((successful / total) * 100).toFixed(1) : "0.0"; - const avgLatencyMs = - successful > 0 - ? Math.round(runtimeMetrics.cumulativeLatencyMs / successful) - : 0; - const liveSyncSnapshot = liveAccountSync?.getSnapshot(); - const guardianStats = refreshGuardian?.getStats(); - const sessionAffinityEntries = sessionAffinityStore?.size() ?? 0; - const lastRequest = - runtimeMetrics.lastRequestAt !== null - ? `${formatWaitTime(now - runtimeMetrics.lastRequestAt)} ago` - : "never"; - - const lines = [ - "Codex Plugin Metrics:", - "", - `Uptime: ${formatWaitTime(uptimeMs)}`, - `Total upstream requests: ${total}`, - `Successful responses: ${successful}`, - `Failed responses: ${runtimeMetrics.failedRequests}`, - `Success rate: ${successRate}%`, - `Average successful latency: ${avgLatencyMs}ms`, - `Rate-limited responses: ${runtimeMetrics.rateLimitedResponses}`, - `Server errors (5xx): ${runtimeMetrics.serverErrors}`, - `Network errors: ${runtimeMetrics.networkErrors}`, - `User aborts: ${runtimeMetrics.userAborts}`, - `Auth refresh failures: ${runtimeMetrics.authRefreshFailures}`, - `Account rotations: ${runtimeMetrics.accountRotations}`, - `Same-account retries: ${runtimeMetrics.sameAccountRetries}`, - `Stream failover attempts: ${runtimeMetrics.streamFailoverAttempts}`, - `Stream failover recoveries: ${runtimeMetrics.streamFailoverRecoveries}`, - `Stream failover cross-account recoveries: ${runtimeMetrics.streamFailoverCrossAccountRecoveries}`, - `Empty-response retries: ${runtimeMetrics.emptyResponseRetries}`, - `Session affinity entries: ${sessionAffinityEntries}`, - `Live sync: ${liveSyncSnapshot?.running ? "on" : "off"} (${liveSyncSnapshot?.reloadCount ?? 0} reloads)`, - `Refresh guardian: ${guardianStats ? "on" : "off"} (${guardianStats?.refreshed ?? 0} refreshed, ${guardianStats?.failed ?? 0} failed)`, - `Last upstream request: ${lastRequest}`, - ]; + if (runtimeMetrics.lastError) { + lines.push(`Last error: ${runtimeMetrics.lastError}`); + } - if (runtimeMetrics.lastError) { - lines.push(`Last error: ${runtimeMetrics.lastError}`); - } + if (ui.v2Enabled) { + const styled: string[] = [ + ...formatUiHeader(ui, "Codex plugin metrics"), + formatUiKeyValue(ui, "Uptime", formatWaitTime(uptimeMs)), + formatUiKeyValue( + ui, + "Total upstream requests", + String(total), + ), + formatUiKeyValue( + ui, + "Successful responses", + String(successful), + "success", + ), + formatUiKeyValue( + ui, + "Failed responses", + String(runtimeMetrics.failedRequests), + "danger", + ), + formatUiKeyValue( + ui, + "Success rate", + `${successRate}%`, + "accent", + ), + formatUiKeyValue( + ui, + "Average successful latency", + `${avgLatencyMs}ms`, + ), + formatUiKeyValue( + ui, + "Rate-limited responses", + String(runtimeMetrics.rateLimitedResponses), + "warning", + ), + formatUiKeyValue( + ui, + "Server errors (5xx)", + String(runtimeMetrics.serverErrors), + "danger", + ), + formatUiKeyValue( + ui, + "Network errors", + String(runtimeMetrics.networkErrors), + "danger", + ), + formatUiKeyValue( + ui, + "User aborts", + String(runtimeMetrics.userAborts), + "muted", + ), + formatUiKeyValue( + ui, + "Auth refresh failures", + String(runtimeMetrics.authRefreshFailures), + "warning", + ), + formatUiKeyValue( + ui, + "Account rotations", + String(runtimeMetrics.accountRotations), + "accent", + ), + formatUiKeyValue( + ui, + "Same-account retries", + String(runtimeMetrics.sameAccountRetries), + "warning", + ), + formatUiKeyValue( + ui, + "Stream failover attempts", + String(runtimeMetrics.streamFailoverAttempts), + "muted", + ), + formatUiKeyValue( + ui, + "Stream failover recoveries", + String(runtimeMetrics.streamFailoverRecoveries), + "success", + ), + formatUiKeyValue( + ui, + "Stream failover cross-account recoveries", + String( + runtimeMetrics.streamFailoverCrossAccountRecoveries, + ), + "accent", + ), + formatUiKeyValue( + ui, + "Empty-response retries", + String(runtimeMetrics.emptyResponseRetries), + "warning", + ), + formatUiKeyValue( + ui, + "Session affinity entries", + String(sessionAffinityEntries), + "muted", + ), + formatUiKeyValue( + ui, + "Live sync", + `${liveSyncSnapshot?.running ? "on" : "off"} (${liveSyncSnapshot?.reloadCount ?? 0} reloads)`, + liveSyncSnapshot?.running ? "success" : "muted", + ), + formatUiKeyValue( + ui, + "Refresh guardian", + guardianStats + ? `on (${guardianStats.refreshed} refreshed, ${guardianStats.failed} failed)` + : "off", + guardianStats ? "success" : "muted", + ), + formatUiKeyValue( + ui, + "Last upstream request", + lastRequest, + "muted", + ), + ]; + if (runtimeMetrics.lastError) { + styled.push( + formatUiKeyValue( + ui, + "Last error", + runtimeMetrics.lastError, + "danger", + ), + ); + } + return Promise.resolve(styled.join("\n")); + } - if (ui.v2Enabled) { - const styled: string[] = [ - ...formatUiHeader(ui, "Codex plugin metrics"), - formatUiKeyValue(ui, "Uptime", formatWaitTime(uptimeMs)), - formatUiKeyValue(ui, "Total upstream requests", String(total)), - formatUiKeyValue(ui, "Successful responses", String(successful), "success"), - formatUiKeyValue(ui, "Failed responses", String(runtimeMetrics.failedRequests), "danger"), - formatUiKeyValue(ui, "Success rate", `${successRate}%`, "accent"), - formatUiKeyValue(ui, "Average successful latency", `${avgLatencyMs}ms`), - formatUiKeyValue(ui, "Rate-limited responses", String(runtimeMetrics.rateLimitedResponses), "warning"), - formatUiKeyValue(ui, "Server errors (5xx)", String(runtimeMetrics.serverErrors), "danger"), - formatUiKeyValue(ui, "Network errors", String(runtimeMetrics.networkErrors), "danger"), - formatUiKeyValue(ui, "User aborts", String(runtimeMetrics.userAborts), "muted"), - formatUiKeyValue(ui, "Auth refresh failures", String(runtimeMetrics.authRefreshFailures), "warning"), - formatUiKeyValue(ui, "Account rotations", String(runtimeMetrics.accountRotations), "accent"), - formatUiKeyValue(ui, "Same-account retries", String(runtimeMetrics.sameAccountRetries), "warning"), - formatUiKeyValue(ui, "Stream failover attempts", String(runtimeMetrics.streamFailoverAttempts), "muted"), - formatUiKeyValue(ui, "Stream failover recoveries", String(runtimeMetrics.streamFailoverRecoveries), "success"), - formatUiKeyValue( - ui, - "Stream failover cross-account recoveries", - String(runtimeMetrics.streamFailoverCrossAccountRecoveries), - "accent", - ), - formatUiKeyValue(ui, "Empty-response retries", String(runtimeMetrics.emptyResponseRetries), "warning"), - formatUiKeyValue(ui, "Session affinity entries", String(sessionAffinityEntries), "muted"), - formatUiKeyValue( - ui, - "Live sync", - `${liveSyncSnapshot?.running ? "on" : "off"} (${liveSyncSnapshot?.reloadCount ?? 0} reloads)`, - liveSyncSnapshot?.running ? "success" : "muted", - ), - formatUiKeyValue( - ui, - "Refresh guardian", - guardianStats - ? `on (${guardianStats.refreshed} refreshed, ${guardianStats.failed} failed)` - : "off", - guardianStats ? "success" : "muted", - ), - formatUiKeyValue(ui, "Last upstream request", lastRequest, "muted"), - ]; - if (runtimeMetrics.lastError) { - styled.push(formatUiKeyValue(ui, "Last error", runtimeMetrics.lastError, "danger")); - } - return Promise.resolve(styled.join("\n")); + return Promise.resolve(lines.join("\n")); + }, + }), } - - return Promise.resolve(lines.join("\n")); - }, - }), - } : {}), - "codex-health": tool({ - description: "Check health of all Codex accounts by validating refresh tokens.", + : {}), + "codex-health": tool({ + description: + "Check health of all Codex accounts by validating refresh tokens.", args: {}, async execute() { const ui = resolveUiRuntime(); @@ -4045,23 +4766,32 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { const label = formatAccountLabel(account, i); try { - const refreshResult = await queuedRefresh(account.refreshToken); + const refreshResult = await queuedRefresh(account.refreshToken); if (refreshResult.type === "success") { - results.push(` ${getStatusMarker(ui, "ok")} ${label}: Healthy`); + results.push( + ` ${getStatusMarker(ui, "ok")} ${label}: Healthy`, + ); healthyCount++; } else { - results.push(` ${getStatusMarker(ui, "error")} ${label}: Token refresh failed`); + results.push( + ` ${getStatusMarker(ui, "error")} ${label}: Token refresh failed`, + ); unhealthyCount++; } } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - results.push(` ${getStatusMarker(ui, "error")} ${label}: Error - ${errorMsg.slice(0, 120)}`); + const errorMsg = + error instanceof Error ? error.message : String(error); + results.push( + ` ${getStatusMarker(ui, "error")} ${label}: Error - ${errorMsg.slice(0, 120)}`, + ); unhealthyCount++; } } results.push(""); - results.push(`Summary: ${healthyCount} healthy, ${unhealthyCount} unhealthy`); + results.push( + `Summary: ${healthyCount} healthy, ${unhealthyCount} unhealthy`, + ); if (ui.v2Enabled) { return [ @@ -4074,275 +4804,366 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { return results.join("\n"); }, }), - ...(exposeAdvancedCodexTools ? { - "codex-remove": tool({ - description: "Remove a Codex account by index (1-based). Use codex-list to list accounts first.", - args: { - index: tool.schema.number().describe( - "Account number to remove (1-based, e.g., 1 for first account)", - ), - }, - async execute({ index }) { - const ui = resolveUiRuntime(); - const storage = await loadAccounts(); - if (!storage || storage.accounts.length === 0) { - if (ui.v2Enabled) { - return [ - ...formatUiHeader(ui, "Remove account"), - "", - formatUiItem(ui, "No accounts configured.", "warning"), - ].join("\n"); - } - return "No Codex accounts configured. Nothing to remove."; - } + ...(exposeAdvancedCodexTools + ? { + "codex-remove": tool({ + description: + "Remove a Codex account by index (1-based). Use codex-list to list accounts first.", + args: { + index: tool.schema + .number() + .describe( + "Account number to remove (1-based, e.g., 1 for first account)", + ), + }, + async execute({ index }) { + const ui = resolveUiRuntime(); + const storage = await loadAccounts(); + if (!storage || storage.accounts.length === 0) { + if (ui.v2Enabled) { + return [ + ...formatUiHeader(ui, "Remove account"), + "", + formatUiItem(ui, "No accounts configured.", "warning"), + ].join("\n"); + } + return "No Codex accounts configured. Nothing to remove."; + } - const targetIndex = Math.floor((index ?? 0) - 1); - if ( - !Number.isFinite(targetIndex) || - targetIndex < 0 || - targetIndex >= storage.accounts.length - ) { - if (ui.v2Enabled) { - return [ - ...formatUiHeader(ui, "Remove account"), - "", - formatUiItem(ui, `Invalid account number: ${index}`, "danger"), - formatUiKeyValue(ui, "Valid range", `1-${storage.accounts.length}`, "muted"), - formatUiItem(ui, "Use codex-list to list all accounts.", "accent"), - ].join("\n"); - } - return `Invalid account number: ${index}\n\nValid range: 1-${storage.accounts.length}\n\nUse codex-list to list all accounts.`; - } + const targetIndex = Math.floor((index ?? 0) - 1); + if ( + !Number.isFinite(targetIndex) || + targetIndex < 0 || + targetIndex >= storage.accounts.length + ) { + if (ui.v2Enabled) { + return [ + ...formatUiHeader(ui, "Remove account"), + "", + formatUiItem( + ui, + `Invalid account number: ${index}`, + "danger", + ), + formatUiKeyValue( + ui, + "Valid range", + `1-${storage.accounts.length}`, + "muted", + ), + formatUiItem( + ui, + "Use codex-list to list all accounts.", + "accent", + ), + ].join("\n"); + } + return `Invalid account number: ${index}\n\nValid range: 1-${storage.accounts.length}\n\nUse codex-list to list all accounts.`; + } - const account = storage.accounts[targetIndex]; - if (!account) { - return `Account ${index} not found.`; - } + const account = storage.accounts[targetIndex]; + if (!account) { + return `Account ${index} not found.`; + } - const label = formatAccountLabel(account, targetIndex); + const label = formatAccountLabel(account, targetIndex); - storage.accounts.splice(targetIndex, 1); + storage.accounts.splice(targetIndex, 1); - if (storage.accounts.length === 0) { - storage.activeIndex = 0; - storage.activeIndexByFamily = {}; - } else { - if (storage.activeIndex >= storage.accounts.length) { - storage.activeIndex = 0; - } else if (storage.activeIndex > targetIndex) { - storage.activeIndex -= 1; - } + if (storage.accounts.length === 0) { + storage.activeIndex = 0; + storage.activeIndexByFamily = {}; + } else { + if (storage.activeIndex >= storage.accounts.length) { + storage.activeIndex = 0; + } else if (storage.activeIndex > targetIndex) { + storage.activeIndex -= 1; + } - if (storage.activeIndexByFamily) { - for (const family of MODEL_FAMILIES) { - const idx = storage.activeIndexByFamily[family]; - if (typeof idx === "number") { - if (idx >= storage.accounts.length) { - storage.activeIndexByFamily[family] = 0; - } else if (idx > targetIndex) { - storage.activeIndexByFamily[family] = idx - 1; + if (storage.activeIndexByFamily) { + for (const family of MODEL_FAMILIES) { + const idx = storage.activeIndexByFamily[family]; + if (typeof idx === "number") { + if (idx >= storage.accounts.length) { + storage.activeIndexByFamily[family] = 0; + } else if (idx > targetIndex) { + storage.activeIndexByFamily[family] = idx - 1; + } + } + } } } - } - } - } - - try { - await saveAccounts(storage); - } catch (saveError) { - logWarn("Failed to save account removal", { error: String(saveError) }); - if (ui.v2Enabled) { - return [ - ...formatUiHeader(ui, "Remove account"), - "", - formatUiItem(ui, `Removed ${formatAccountLabel(account, targetIndex)} from memory`, "warning"), - formatUiItem(ui, "Failed to persist. Change may be lost on restart.", "danger"), - ].join("\n"); - } - return `Removed ${formatAccountLabel(account, targetIndex)} from memory but failed to persist. Changes may be lost on restart.`; - } - if (cachedAccountManager) { - await reloadAccountManagerFromDisk(); - } + try { + await saveAccounts(storage); + } catch (saveError) { + logWarn("Failed to save account removal", { + error: String(saveError), + }); + if (ui.v2Enabled) { + return [ + ...formatUiHeader(ui, "Remove account"), + "", + formatUiItem( + ui, + `Removed ${formatAccountLabel(account, targetIndex)} from memory`, + "warning", + ), + formatUiItem( + ui, + "Failed to persist. Change may be lost on restart.", + "danger", + ), + ].join("\n"); + } + return `Removed ${formatAccountLabel(account, targetIndex)} from memory but failed to persist. Changes may be lost on restart.`; + } - const remaining = storage.accounts.length; - if (ui.v2Enabled) { - return [ - ...formatUiHeader(ui, "Remove account"), - "", - formatUiItem(ui, `${getStatusMarker(ui, "ok")} Removed: ${label}`, "success"), - remaining > 0 - ? formatUiKeyValue(ui, "Remaining accounts", String(remaining)) - : formatUiItem(ui, "No accounts remaining. Run: codex login", "warning"), - ].join("\n"); - } - return [ - `Removed: ${label}`, - "", - remaining > 0 - ? `Remaining accounts: ${remaining}` - : "No accounts remaining. Run: codex login", - ].join("\n"); - }, - }), + if (cachedAccountManager) { + await reloadAccountManagerFromDisk(); + } - "codex-refresh": tool({ - description: "Manually refresh OAuth tokens for all accounts to verify they're still valid.", - args: {}, - async execute() { - const ui = resolveUiRuntime(); - const storage = await loadAccounts(); - if (!storage || storage.accounts.length === 0) { - if (ui.v2Enabled) { - return [ - ...formatUiHeader(ui, "Refresh accounts"), - "", - formatUiItem(ui, "No accounts configured.", "warning"), - formatUiItem(ui, "Run: codex login", "accent"), - ].join("\n"); - } - return "No Codex accounts configured. Run: codex login"; - } + const remaining = storage.accounts.length; + if (ui.v2Enabled) { + return [ + ...formatUiHeader(ui, "Remove account"), + "", + formatUiItem( + ui, + `${getStatusMarker(ui, "ok")} Removed: ${label}`, + "success", + ), + remaining > 0 + ? formatUiKeyValue( + ui, + "Remaining accounts", + String(remaining), + ) + : formatUiItem( + ui, + "No accounts remaining. Run: codex login", + "warning", + ), + ].join("\n"); + } + return [ + `Removed: ${label}`, + "", + remaining > 0 + ? `Remaining accounts: ${remaining}` + : "No accounts remaining. Run: codex login", + ].join("\n"); + }, + }), + + "codex-refresh": tool({ + description: + "Manually refresh OAuth tokens for all accounts to verify they're still valid.", + args: {}, + async execute() { + const ui = resolveUiRuntime(); + const storage = await loadAccounts(); + if (!storage || storage.accounts.length === 0) { + if (ui.v2Enabled) { + return [ + ...formatUiHeader(ui, "Refresh accounts"), + "", + formatUiItem(ui, "No accounts configured.", "warning"), + formatUiItem(ui, "Run: codex login", "accent"), + ].join("\n"); + } + return "No Codex accounts configured. Run: codex login"; + } - const results: string[] = ui.v2Enabled - ? [] - : [`Refreshing ${storage.accounts.length} account(s):`, ""]; + const results: string[] = ui.v2Enabled + ? [] + : [`Refreshing ${storage.accounts.length} account(s):`, ""]; - let refreshedCount = 0; - let failedCount = 0; + let refreshedCount = 0; + let failedCount = 0; - for (let i = 0; i < storage.accounts.length; i++) { - const account = storage.accounts[i]; - if (!account) continue; - const label = formatAccountLabel(account, i); + for (let i = 0; i < storage.accounts.length; i++) { + const account = storage.accounts[i]; + if (!account) continue; + const label = formatAccountLabel(account, i); - try { - const refreshResult = await queuedRefresh(account.refreshToken); - if (refreshResult.type === "success") { - account.refreshToken = refreshResult.refresh; - account.accessToken = refreshResult.access; - account.expiresAt = refreshResult.expires; - results.push(` ${getStatusMarker(ui, "ok")} ${label}: Refreshed`); - refreshedCount++; - } else { - results.push(` ${getStatusMarker(ui, "error")} ${label}: Failed - ${refreshResult.message ?? refreshResult.reason}`); - failedCount++; - } - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - results.push(` ${getStatusMarker(ui, "error")} ${label}: Error - ${errorMsg.slice(0, 120)}`); - failedCount++; - } - } + try { + const refreshResult = await queuedRefresh( + account.refreshToken, + ); + if (refreshResult.type === "success") { + account.refreshToken = refreshResult.refresh; + account.accessToken = refreshResult.access; + account.expiresAt = refreshResult.expires; + results.push( + ` ${getStatusMarker(ui, "ok")} ${label}: Refreshed`, + ); + refreshedCount++; + } else { + results.push( + ` ${getStatusMarker(ui, "error")} ${label}: Failed - ${refreshResult.message ?? refreshResult.reason}`, + ); + failedCount++; + } + } catch (error) { + const errorMsg = + error instanceof Error ? error.message : String(error); + results.push( + ` ${getStatusMarker(ui, "error")} ${label}: Error - ${errorMsg.slice(0, 120)}`, + ); + failedCount++; + } + } - await saveAccounts(storage); - if (cachedAccountManager) { - await reloadAccountManagerFromDisk(); - } - results.push(""); - results.push(`Summary: ${refreshedCount} refreshed, ${failedCount} failed`); - if (ui.v2Enabled) { - return [ - ...formatUiHeader(ui, "Refresh accounts"), - "", - ...results.map((line) => paintUiText(ui, line, "normal")), - ].join("\n"); - } - return results.join("\n"); - }, - }), - - "codex-export": tool({ - description: "Export accounts to a JSON file for backup or migration to another machine.", - args: { - path: tool.schema.string().describe( - "File path to export to (e.g., ~/codex-backup.json)" - ), - force: tool.schema.boolean().optional().describe( - "Overwrite existing file (default: true)" - ), - }, - async execute({ path: filePath, force }) { - const ui = resolveUiRuntime(); - try { - await exportAccounts(filePath, force ?? true); - const storage = await loadAccounts(); - const count = storage?.accounts.length ?? 0; - if (ui.v2Enabled) { - return [ - ...formatUiHeader(ui, "Export accounts"), - "", - formatUiItem(ui, `${getStatusMarker(ui, "ok")} Exported ${count} account(s)`, "success"), - formatUiKeyValue(ui, "Path", filePath, "muted"), - ].join("\n"); - } - return `Exported ${count} account(s) to: ${filePath}`; - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - if (ui.v2Enabled) { - return [ - ...formatUiHeader(ui, "Export accounts"), - "", - formatUiItem(ui, `${getStatusMarker(ui, "error")} Export failed`, "danger"), - formatUiKeyValue(ui, "Error", msg, "danger"), - ].join("\n"); - } - return `Export failed: ${msg}`; - } - }, - }), - - "codex-import": tool({ - description: "Import accounts from a JSON file, merging with existing accounts.", - args: { - path: tool.schema.string().describe( - "File path to import from (e.g., ~/codex-backup.json)" - ), - }, - async execute({ path: filePath }) { - const ui = resolveUiRuntime(); - try { - const result = await importAccounts(filePath); - invalidateAccountManagerCache(); - const lines = [`Import complete.`, ``]; - if (result.imported > 0) { - lines.push(`New accounts: ${result.imported}`); - } - if (result.skipped > 0) { - lines.push(`Duplicates skipped: ${result.skipped}`); - } - lines.push(`Total accounts: ${result.total}`); - if (ui.v2Enabled) { - const styled = [ - ...formatUiHeader(ui, "Import accounts"), - "", - formatUiItem(ui, `${getStatusMarker(ui, "ok")} Import complete`, "success"), - formatUiKeyValue(ui, "Path", filePath, "muted"), - formatUiKeyValue(ui, "New accounts", String(result.imported), result.imported > 0 ? "success" : "muted"), - formatUiKeyValue(ui, "Duplicates skipped", String(result.skipped), result.skipped > 0 ? "warning" : "muted"), - formatUiKeyValue(ui, "Total accounts", String(result.total), "accent"), - ]; - return styled.join("\n"); - } - return lines.join("\n"); - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - if (ui.v2Enabled) { - return [ - ...formatUiHeader(ui, "Import accounts"), - "", - formatUiItem(ui, `${getStatusMarker(ui, "error")} Import failed`, "danger"), - formatUiKeyValue(ui, "Error", msg, "danger"), - ].join("\n"); + await saveAccounts(storage); + if (cachedAccountManager) { + await reloadAccountManagerFromDisk(); + } + results.push(""); + results.push( + `Summary: ${refreshedCount} refreshed, ${failedCount} failed`, + ); + if (ui.v2Enabled) { + return [ + ...formatUiHeader(ui, "Refresh accounts"), + "", + ...results.map((line) => paintUiText(ui, line, "normal")), + ].join("\n"); + } + return results.join("\n"); + }, + }), + + "codex-export": tool({ + description: + "Export accounts to a JSON file for backup or migration to another machine.", + args: { + path: tool.schema + .string() + .describe( + "File path to export to (e.g., ~/codex-backup.json)", + ), + force: tool.schema + .boolean() + .optional() + .describe("Overwrite existing file (default: true)"), + }, + async execute({ path: filePath, force }) { + const ui = resolveUiRuntime(); + try { + await exportAccounts(filePath, force ?? true); + const storage = await loadAccounts(); + const count = storage?.accounts.length ?? 0; + if (ui.v2Enabled) { + return [ + ...formatUiHeader(ui, "Export accounts"), + "", + formatUiItem( + ui, + `${getStatusMarker(ui, "ok")} Exported ${count} account(s)`, + "success", + ), + formatUiKeyValue(ui, "Path", filePath, "muted"), + ].join("\n"); + } + return `Exported ${count} account(s) to: ${filePath}`; + } catch (error) { + const msg = + error instanceof Error ? error.message : String(error); + if (ui.v2Enabled) { + return [ + ...formatUiHeader(ui, "Export accounts"), + "", + formatUiItem( + ui, + `${getStatusMarker(ui, "error")} Export failed`, + "danger", + ), + formatUiKeyValue(ui, "Error", msg, "danger"), + ].join("\n"); + } + return `Export failed: ${msg}`; + } + }, + }), + + "codex-import": tool({ + description: + "Import accounts from a JSON file, merging with existing accounts.", + args: { + path: tool.schema + .string() + .describe( + "File path to import from (e.g., ~/codex-backup.json)", + ), + }, + async execute({ path: filePath }) { + const ui = resolveUiRuntime(); + try { + const result = await importAccounts(filePath); + invalidateAccountManagerCache(); + const lines = [`Import complete.`, ``]; + if (result.imported > 0) { + lines.push(`New accounts: ${result.imported}`); + } + if (result.skipped > 0) { + lines.push(`Duplicates skipped: ${result.skipped}`); + } + lines.push(`Total accounts: ${result.total}`); + if (ui.v2Enabled) { + const styled = [ + ...formatUiHeader(ui, "Import accounts"), + "", + formatUiItem( + ui, + `${getStatusMarker(ui, "ok")} Import complete`, + "success", + ), + formatUiKeyValue(ui, "Path", filePath, "muted"), + formatUiKeyValue( + ui, + "New accounts", + String(result.imported), + result.imported > 0 ? "success" : "muted", + ), + formatUiKeyValue( + ui, + "Duplicates skipped", + String(result.skipped), + result.skipped > 0 ? "warning" : "muted", + ), + formatUiKeyValue( + ui, + "Total accounts", + String(result.total), + "accent", + ), + ]; + return styled.join("\n"); + } + return lines.join("\n"); + } catch (error) { + const msg = + error instanceof Error ? error.message : String(error); + if (ui.v2Enabled) { + return [ + ...formatUiHeader(ui, "Import accounts"), + "", + formatUiItem( + ui, + `${getStatusMarker(ui, "error")} Import failed`, + "danger", + ), + formatUiKeyValue(ui, "Error", msg, "danger"), + ].join("\n"); + } + return `Import failed: ${msg}`; + } + }, + }), } - return `Import failed: ${msg}`; - } - }, - }), - } : {}), - - }, + : {}), + }, }; }; diff --git a/lib/runtime/metrics.ts b/lib/runtime/metrics.ts new file mode 100644 index 00000000..b8d64ea6 --- /dev/null +++ b/lib/runtime/metrics.ts @@ -0,0 +1,124 @@ +import type { FailoverMode } from "../request/failure-policy.js"; + +export const MAX_RETRY_HINT_MS = 5 * 60 * 1000; + +export type RuntimeMetrics = { + startedAt: number; + totalRequests: number; + successfulRequests: number; + failedRequests: number; + rateLimitedResponses: number; + serverErrors: number; + networkErrors: number; + userAborts: number; + authRefreshFailures: number; + emptyResponseRetries: number; + accountRotations: number; + sameAccountRetries: number; + streamFailoverAttempts: number; + streamFailoverRecoveries: number; + streamFailoverCrossAccountRecoveries: number; + cumulativeLatencyMs: number; + lastRequestAt: number | null; + lastError: string | null; +}; + +export function createRuntimeMetrics(now = Date.now()): RuntimeMetrics { + return { + startedAt: now, + totalRequests: 0, + successfulRequests: 0, + failedRequests: 0, + rateLimitedResponses: 0, + serverErrors: 0, + networkErrors: 0, + userAborts: 0, + authRefreshFailures: 0, + emptyResponseRetries: 0, + accountRotations: 0, + sameAccountRetries: 0, + streamFailoverAttempts: 0, + streamFailoverRecoveries: 0, + streamFailoverCrossAccountRecoveries: 0, + cumulativeLatencyMs: 0, + lastRequestAt: null, + lastError: null, + }; +} + +export function parseFailoverMode(value: string | undefined): FailoverMode { + const normalized = (value ?? "").trim().toLowerCase(); + if (normalized === "aggressive") return "aggressive"; + if (normalized === "conservative") return "conservative"; + return "balanced"; +} + +export function parseEnvInt(value: string | undefined): number | undefined { + if (value === undefined) return undefined; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : undefined; +} + +export function clampRetryHintMs(value: number): number | null { + if (!Number.isFinite(value)) return null; + const normalized = Math.floor(value); + if (normalized <= 0) return null; + return Math.min(normalized, MAX_RETRY_HINT_MS); +} + +export function parseRetryAfterHintMs(headers: Headers): number | null { + const retryAfterMsHeader = headers.get("retry-after-ms")?.trim(); + if (retryAfterMsHeader && /^\d+$/.test(retryAfterMsHeader)) { + return clampRetryHintMs(Number.parseInt(retryAfterMsHeader, 10)); + } + + const retryAfterHeader = headers.get("retry-after")?.trim(); + if (retryAfterHeader && /^\d+$/.test(retryAfterHeader)) { + return clampRetryHintMs(Number.parseInt(retryAfterHeader, 10) * 1000); + } + if (retryAfterHeader) { + const retryAtMs = Date.parse(retryAfterHeader); + if (Number.isFinite(retryAtMs)) { + return clampRetryHintMs(retryAtMs - Date.now()); + } + } + + const resetAtHeader = headers.get("x-ratelimit-reset")?.trim(); + if (resetAtHeader && /^\d+$/.test(resetAtHeader)) { + const resetRaw = Number.parseInt(resetAtHeader, 10); + const resetAtMs = resetRaw < 10_000_000_000 ? resetRaw * 1000 : resetRaw; + return clampRetryHintMs(resetAtMs - Date.now()); + } + + return null; +} + +export function sanitizeResponseHeadersForLog( + headers: Headers, +): Record { + const allowed = new Set([ + "content-type", + "x-request-id", + "x-openai-request-id", + "x-codex-plan-type", + "x-codex-active-limit", + "x-codex-primary-used-percent", + "x-codex-primary-window-minutes", + "x-codex-primary-reset-at", + "x-codex-primary-reset-after-seconds", + "x-codex-secondary-used-percent", + "x-codex-secondary-window-minutes", + "x-codex-secondary-reset-at", + "x-codex-secondary-reset-after-seconds", + "retry-after", + "x-ratelimit-reset", + "x-ratelimit-reset-requests", + ]); + const sanitized: Record = {}; + for (const [rawName, rawValue] of headers.entries()) { + const name = rawName.toLowerCase(); + if (!allowed.has(name)) continue; + sanitized[name] = rawValue; + } + return sanitized; +} From eb10a1f2866bdd2c18b5ef38b36a8f8988c46571 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 12:22:25 +0800 Subject: [PATCH 067/376] refactor: extract backup metadata helpers --- lib/storage.ts | 49 ++++---------------------------- lib/storage/backup-metadata.ts | 52 ++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 44 deletions(-) create mode 100644 lib/storage/backup-metadata.ts diff --git a/lib/storage.ts b/lib/storage.ts index ec257578..07ecd185 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -11,6 +11,11 @@ import { } from "./named-backup-export.js"; import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; import { AnyAccountStorageSchema, getValidationErrors } from "./schemas.js"; +import { + type BackupMetadataSection, + type BackupSnapshotMetadata, + buildMetadataSection, +} from "./storage/backup-metadata.js"; import { formatStorageErrorHint } from "./storage/error-hints.js"; import { collectNamedBackups, @@ -108,28 +113,6 @@ type BackupSnapshotKind = | "flagged-backup-history" | "flagged-discovered-backup"; -type BackupSnapshotMetadata = { - kind: BackupSnapshotKind; - path: string; - index?: number; - exists: boolean; - valid: boolean; - bytes?: number; - mtimeMs?: number; - version?: number; - accountCount?: number; - flaggedCount?: number; - schemaErrors?: string[]; -}; - -type BackupMetadataSection = { - storagePath: string; - latestValidPath?: string; - snapshotCount: number; - validSnapshotCount: number; - snapshots: BackupSnapshotMetadata[]; -}; - export type BackupMetadata = { accounts: BackupMetadataSection; flaggedAccounts: BackupMetadataSection; @@ -690,28 +673,6 @@ async function describeFlaggedSnapshot( } } -function latestValidSnapshot( - snapshots: BackupSnapshotMetadata[], -): BackupSnapshotMetadata | undefined { - return snapshots - .filter((snapshot) => snapshot.valid) - .sort((left, right) => (right.mtimeMs ?? 0) - (left.mtimeMs ?? 0))[0]; -} - -function buildMetadataSection( - storagePath: string, - snapshots: BackupSnapshotMetadata[], -): BackupMetadataSection { - const latestValid = latestValidSnapshot(snapshots); - return { - storagePath, - latestValidPath: latestValid?.path, - snapshotCount: snapshots.length, - validSnapshotCount: snapshots.filter((snapshot) => snapshot.valid).length, - snapshots, - }; -} - type AccountsJournalEntry = { version: 1; createdAt: number; diff --git a/lib/storage/backup-metadata.ts b/lib/storage/backup-metadata.ts new file mode 100644 index 00000000..b2a7b14d --- /dev/null +++ b/lib/storage/backup-metadata.ts @@ -0,0 +1,52 @@ +export type BackupSnapshotMetadata = { + kind: + | "accounts-primary" + | "accounts-wal" + | "accounts-backup" + | "accounts-backup-history" + | "accounts-discovered-backup" + | "flagged-primary" + | "flagged-backup" + | "flagged-backup-history" + | "flagged-discovered-backup"; + path: string; + index?: number; + exists: boolean; + valid: boolean; + bytes?: number; + mtimeMs?: number; + version?: number; + accountCount?: number; + flaggedCount?: number; + schemaErrors?: string[]; +}; + +export type BackupMetadataSection = { + storagePath: string; + latestValidPath?: string; + snapshotCount: number; + validSnapshotCount: number; + snapshots: BackupSnapshotMetadata[]; +}; + +export function latestValidSnapshot( + snapshots: BackupSnapshotMetadata[], +): BackupSnapshotMetadata | undefined { + return snapshots + .filter((snapshot) => snapshot.valid) + .sort((left, right) => (right.mtimeMs ?? 0) - (left.mtimeMs ?? 0))[0]; +} + +export function buildMetadataSection( + storagePath: string, + snapshots: BackupSnapshotMetadata[], +): BackupMetadataSection { + const latestValid = latestValidSnapshot(snapshots); + return { + storagePath, + latestValidPath: latestValid?.path, + snapshotCount: snapshots.length, + validSnapshotCount: snapshots.filter((snapshot) => snapshot.valid).length, + snapshots, + }; +} From a3d6bf676010d6e767415ea174274334b8359c68 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 12:25:27 +0800 Subject: [PATCH 068/376] refactor: extract backup restore helper --- lib/storage.ts | 66 ++++++++------------------------------- lib/storage/restore.ts | 71 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 53 deletions(-) create mode 100644 lib/storage/restore.ts diff --git a/lib/storage.ts b/lib/storage.ts index 07ecd185..59095c22 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1,7 +1,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { createHash } from "node:crypto"; import { existsSync, promises as fs } from "node:fs"; -import { basename, dirname, isAbsolute, join, relative } from "node:path"; +import { basename, dirname, join } from "node:path"; import { ACCOUNT_LIMITS } from "./constants.js"; import { createLogger } from "./logger.js"; import { @@ -62,6 +62,7 @@ import { resolvePath, resolveProjectStorageIdentityRoot, } from "./storage/paths.js"; +import { restoreAccountsFromBackupFile } from "./storage/restore.js"; export type { CooldownReason, @@ -769,58 +770,17 @@ export async function restoreAccountsFromBackup( path: string, options?: { persist?: boolean }, ): Promise { - const backupRoot = getNamedBackupRoot(getStoragePath()); - let resolvedBackupRoot: string; - try { - resolvedBackupRoot = await fs.realpath(backupRoot); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - throw new Error(`Backup root does not exist: ${backupRoot}`); - } - throw error; - } - let resolvedBackupPath: string; - try { - resolvedBackupPath = await fs.realpath(path); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - throw new Error(`Backup file no longer exists: ${path}`); - } - throw error; - } - const relativePath = relative(resolvedBackupRoot, resolvedBackupPath); - const isInsideBackupRoot = - relativePath.length > 0 && - !relativePath.startsWith("..") && - !isAbsolute(relativePath); - if (!isInsideBackupRoot) { - throw new Error( - `Backup path must stay inside ${resolvedBackupRoot}: ${path}`, - ); - } - - const { normalized } = await (async () => { - try { - return await loadAccountsFromPath(resolvedBackupPath); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - throw new Error(`Backup file no longer exists: ${path}`); - } - throw error; - } - })(); - if (!normalized || normalized.accounts.length === 0) { - throw new Error( - `Backup does not contain any accounts: ${resolvedBackupPath}`, - ); - } - if (options?.persist !== false) { - await saveAccounts(normalized); - } - return normalized; + return restoreAccountsFromBackupFile( + path, + { + realpath: fs.realpath, + getNamedBackupRoot, + getStoragePath, + loadAccountsFromPath, + saveAccounts, + }, + options, + ); } export async function exportNamedBackup( diff --git a/lib/storage/restore.ts b/lib/storage/restore.ts new file mode 100644 index 00000000..cad7dbaf --- /dev/null +++ b/lib/storage/restore.ts @@ -0,0 +1,71 @@ +import { isAbsolute, relative } from "node:path"; +import type { AccountStorageV3 } from "../storage.js"; + +export interface RestoreAccountsFromBackupDeps { + realpath: typeof import("node:fs").promises.realpath; + getNamedBackupRoot: (storagePath: string) => string; + getStoragePath: () => string; + loadAccountsFromPath: ( + path: string, + ) => Promise<{ normalized: AccountStorageV3 | null }>; + saveAccounts: (storage: AccountStorageV3) => Promise; +} + +export async function restoreAccountsFromBackupFile( + path: string, + deps: RestoreAccountsFromBackupDeps, + options?: { persist?: boolean }, +): Promise { + const backupRoot = deps.getNamedBackupRoot(deps.getStoragePath()); + let resolvedBackupRoot: string; + try { + resolvedBackupRoot = await deps.realpath(backupRoot); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + throw new Error(`Backup root does not exist: ${backupRoot}`); + } + throw error; + } + let resolvedBackupPath: string; + try { + resolvedBackupPath = await deps.realpath(path); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + throw new Error(`Backup file no longer exists: ${path}`); + } + throw error; + } + const relativePath = relative(resolvedBackupRoot, resolvedBackupPath); + const isInsideBackupRoot = + relativePath.length > 0 && + !relativePath.startsWith("..") && + !isAbsolute(relativePath); + if (!isInsideBackupRoot) { + throw new Error( + `Backup path must stay inside ${resolvedBackupRoot}: ${path}`, + ); + } + + const { normalized } = await (async () => { + try { + return await deps.loadAccountsFromPath(resolvedBackupPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + throw new Error(`Backup file no longer exists: ${path}`); + } + throw error; + } + })(); + if (!normalized || normalized.accounts.length === 0) { + throw new Error( + `Backup does not contain any accounts: ${resolvedBackupPath}`, + ); + } + if (options?.persist !== false) { + await deps.saveAccounts(normalized); + } + return normalized; +} From a7b3cfbc79d6c8e480367057eca045259165c967 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 12:41:54 +0800 Subject: [PATCH 069/376] fix: break storage error helper cycle --- lib/errors.ts | 23 +++++++++++++++++++++++ lib/storage.ts | 25 ++----------------------- lib/storage/error-hints.ts | 2 +- test/storage.test.ts | 17 ++++++++++++++++- 4 files changed, 42 insertions(+), 25 deletions(-) diff --git a/lib/errors.ts b/lib/errors.ts index a44081cd..334ea591 100644 --- a/lib/errors.ts +++ b/lib/errors.ts @@ -164,3 +164,26 @@ export class CodexRateLimitError extends CodexError { this.accountId = options?.accountId; } } + +/** + * Storage-specific error with a filesystem code, target path, and user-facing hint. + */ +export class StorageError extends Error { + readonly code: string; + readonly path: string; + readonly hint: string; + + constructor( + message: string, + code: string, + path: string, + hint: string, + cause?: Error, + ) { + super(message, { cause }); + this.name = "StorageError"; + this.code = code; + this.path = path; + this.hint = hint; + } +} diff --git a/lib/storage.ts b/lib/storage.ts index aabbe776..873a332f 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -3,6 +3,7 @@ import { createHash } from "node:crypto"; import { existsSync, promises as fs } from "node:fs"; import { basename, dirname, isAbsolute, join, relative } from "node:path"; import { ACCOUNT_LIMITS } from "./constants.js"; +import { StorageError } from "./errors.js"; import { createLogger } from "./logger.js"; import { exportNamedBackupFile, @@ -14,6 +15,7 @@ import { AnyAccountStorageSchema, getValidationErrors } from "./schemas.js"; import { formatStorageErrorHint } from "./storage/error-hints.js"; +export { StorageError } from "./errors.js"; export { formatStorageErrorHint } from "./storage/error-hints.js"; import { @@ -196,29 +198,6 @@ async function collectNamedBackups( return candidates; } -/** - * Custom error class for storage operations with platform-aware hints. - */ -export class StorageError extends Error { - readonly code: string; - readonly path: string; - readonly hint: string; - - constructor( - message: string, - code: string, - path: string, - hint: string, - cause?: Error, - ) { - super(message, { cause }); - this.name = "StorageError"; - this.code = code; - this.path = path; - this.hint = hint; - } -} - let storageMutex: Promise = Promise.resolve(); const transactionSnapshotContext = new AsyncLocalStorage<{ snapshot: AccountStorageV3 | null; diff --git a/lib/storage/error-hints.ts b/lib/storage/error-hints.ts index dd0b78ce..03cc4c86 100644 --- a/lib/storage/error-hints.ts +++ b/lib/storage/error-hints.ts @@ -1,4 +1,4 @@ -import { StorageError } from "../storage.js"; +import { StorageError } from "../errors.js"; export function formatStorageErrorHint(error: unknown, path: string): string { const err = error as NodeJS.ErrnoException; diff --git a/test/storage.test.ts b/test/storage.test.ts index 32480c74..46030255 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -27,7 +27,7 @@ import { withAccountAndFlaggedStorageTransaction, withAccountStorageTransaction, } from "../lib/storage.js"; -import { formatStorageErrorHint } from "../lib/storage/error-hints.js"; +import { toStorageError } from "../lib/storage/error-hints.js"; // Mocking the behavior we're about to implement for TDD // Since the functions aren't in lib/storage.ts yet, we'll need to mock them or @@ -79,6 +79,21 @@ describe("storage", () => { expect(error.hint).toContain("Permission denied writing"); expect(error.path).toBe("/tmp/openai-codex-accounts.json"); }); + + it("wraps unknown failures with a StorageError", () => { + const cause = Object.assign(new Error("file locked"), { code: "EBUSY" }); + const error = toStorageError( + "failed to persist accounts", + cause, + "/tmp/openai-codex-accounts.json", + ); + + expect(error).toBeInstanceOf(StorageError); + expect(error.code).toBe("EBUSY"); + expect(error.path).toBe("/tmp/openai-codex-accounts.json"); + expect(error.hint).toContain("File is locked"); + expect(error.cause).toBe(cause); + }); }); describe("deduplication", () => { it("preserves activeIndexByFamily when shared accountId entries remain distinct without email", () => { From 8636163548e4eb5ba427e64c8505ca075eac84b3 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 12:41:54 +0800 Subject: [PATCH 070/376] fix: break storage error helper cycle --- lib/errors.ts | 23 +++++++++++++++++++++++ lib/storage.ts | 25 ++----------------------- lib/storage/error-hints.ts | 2 +- test/storage.test.ts | 17 ++++++++++++++++- 4 files changed, 42 insertions(+), 25 deletions(-) diff --git a/lib/errors.ts b/lib/errors.ts index a44081cd..334ea591 100644 --- a/lib/errors.ts +++ b/lib/errors.ts @@ -164,3 +164,26 @@ export class CodexRateLimitError extends CodexError { this.accountId = options?.accountId; } } + +/** + * Storage-specific error with a filesystem code, target path, and user-facing hint. + */ +export class StorageError extends Error { + readonly code: string; + readonly path: string; + readonly hint: string; + + constructor( + message: string, + code: string, + path: string, + hint: string, + cause?: Error, + ) { + super(message, { cause }); + this.name = "StorageError"; + this.code = code; + this.path = path; + this.hint = hint; + } +} diff --git a/lib/storage.ts b/lib/storage.ts index 12c77dac..184ca34f 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -3,6 +3,7 @@ import { createHash } from "node:crypto"; import { existsSync, promises as fs } from "node:fs"; import { basename, dirname, isAbsolute, join, relative } from "node:path"; import { ACCOUNT_LIMITS } from "./constants.js"; +import { StorageError } from "./errors.js"; import { createLogger } from "./logger.js"; import { exportNamedBackupFile, @@ -14,6 +15,7 @@ import { AnyAccountStorageSchema, getValidationErrors } from "./schemas.js"; import { formatStorageErrorHint } from "./storage/error-hints.js"; +export { StorageError } from "./errors.js"; export { formatStorageErrorHint } from "./storage/error-hints.js"; export { getAccountIdentityKey, @@ -204,29 +206,6 @@ async function collectNamedBackups( return candidates; } -/** - * Custom error class for storage operations with platform-aware hints. - */ -export class StorageError extends Error { - readonly code: string; - readonly path: string; - readonly hint: string; - - constructor( - message: string, - code: string, - path: string, - hint: string, - cause?: Error, - ) { - super(message, { cause }); - this.name = "StorageError"; - this.code = code; - this.path = path; - this.hint = hint; - } -} - let storageMutex: Promise = Promise.resolve(); const transactionSnapshotContext = new AsyncLocalStorage<{ snapshot: AccountStorageV3 | null; diff --git a/lib/storage/error-hints.ts b/lib/storage/error-hints.ts index dd0b78ce..03cc4c86 100644 --- a/lib/storage/error-hints.ts +++ b/lib/storage/error-hints.ts @@ -1,4 +1,4 @@ -import { StorageError } from "../storage.js"; +import { StorageError } from "../errors.js"; export function formatStorageErrorHint(error: unknown, path: string): string { const err = error as NodeJS.ErrnoException; diff --git a/test/storage.test.ts b/test/storage.test.ts index 32480c74..46030255 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -27,7 +27,7 @@ import { withAccountAndFlaggedStorageTransaction, withAccountStorageTransaction, } from "../lib/storage.js"; -import { formatStorageErrorHint } from "../lib/storage/error-hints.js"; +import { toStorageError } from "../lib/storage/error-hints.js"; // Mocking the behavior we're about to implement for TDD // Since the functions aren't in lib/storage.ts yet, we'll need to mock them or @@ -79,6 +79,21 @@ describe("storage", () => { expect(error.hint).toContain("Permission denied writing"); expect(error.path).toBe("/tmp/openai-codex-accounts.json"); }); + + it("wraps unknown failures with a StorageError", () => { + const cause = Object.assign(new Error("file locked"), { code: "EBUSY" }); + const error = toStorageError( + "failed to persist accounts", + cause, + "/tmp/openai-codex-accounts.json", + ); + + expect(error).toBeInstanceOf(StorageError); + expect(error.code).toBe("EBUSY"); + expect(error.path).toBe("/tmp/openai-codex-accounts.json"); + expect(error.hint).toContain("File is locked"); + expect(error.cause).toBe(cause); + }); }); describe("deduplication", () => { it("preserves activeIndexByFamily when shared accountId entries remain distinct without email", () => { From 39bf72a99bd5f7f17f830559ea277eba7ea02fde Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 12:44:52 +0800 Subject: [PATCH 071/376] fix: tighten storage identity key surface --- lib/storage.ts | 40 +++++++++++++++++++++++++++++---- lib/storage/identity.ts | 20 +++++++++++------ test/storage.test.ts | 49 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 11 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index 184ca34f..b8a9ea56 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -22,10 +22,7 @@ export { normalizeEmailKey, } from "./storage/identity.js"; -import { - type AccountIdentityRef, - toAccountIdentityRef, -} from "./storage/identity.js"; +import { normalizeEmailKey } from "./storage/identity.js"; import { type AccountMetadataV1, type AccountMetadataV3, @@ -232,6 +229,41 @@ type AccountLike = { lastUsed?: number; }; +type AccountIdentityRef = { + accountId?: string; + emailKey?: string; + refreshToken?: string; +}; + +function normalizeAccountIdKey( + accountId: string | undefined, +): string | undefined { + if (!accountId) return undefined; + const trimmed = accountId.trim(); + return trimmed || undefined; +} + +function normalizeRefreshTokenKey( + refreshToken: string | undefined, +): string | undefined { + if (!refreshToken) return undefined; + const trimmed = refreshToken.trim(); + return trimmed || undefined; +} + +function toAccountIdentityRef( + account: + | Pick + | null + | undefined, +): AccountIdentityRef { + return { + accountId: normalizeAccountIdKey(account?.accountId), + emailKey: normalizeEmailKey(account?.email), + refreshToken: normalizeRefreshTokenKey(account?.refreshToken), + }; +} + function looksLikeSyntheticFixtureAccount(account: AccountMetadataV3): boolean { const email = typeof account.email === "string" ? account.email.trim().toLowerCase() : ""; diff --git a/lib/storage/identity.ts b/lib/storage/identity.ts index 6fc21e7c..d6ab4b70 100644 --- a/lib/storage/identity.ts +++ b/lib/storage/identity.ts @@ -1,18 +1,18 @@ +import { createHash } from "node:crypto"; + type AccountLike = { accountId?: string; email?: string; refreshToken?: string; }; -export type AccountIdentityRef = { +type AccountIdentityRef = { accountId?: string; emailKey?: string; refreshToken?: string; }; -export function normalizeAccountIdKey( - accountId: string | undefined, -): string | undefined { +function normalizeAccountIdKey(accountId: string | undefined): string | undefined { if (!accountId) return undefined; const trimmed = accountId.trim(); return trimmed || undefined; @@ -27,7 +27,7 @@ export function normalizeEmailKey( return trimmed.toLowerCase(); } -export function normalizeRefreshTokenKey( +function normalizeRefreshTokenKey( refreshToken: string | undefined, ): string | undefined { if (!refreshToken) return undefined; @@ -35,7 +35,11 @@ export function normalizeRefreshTokenKey( return trimmed || undefined; } -export function toAccountIdentityRef( +function hashRefreshTokenKey(refreshToken: string): string { + return createHash("sha256").update(refreshToken).digest("hex"); +} + +function toAccountIdentityRef( account: | Pick | null @@ -57,6 +61,8 @@ export function getAccountIdentityKey( } if (ref.accountId) return `account:${ref.accountId}`; if (ref.emailKey) return `email:${ref.emailKey}`; - if (ref.refreshToken) return `refresh:${ref.refreshToken}`; + if (ref.refreshToken) { + return `refresh:${hashRefreshTokenKey(ref.refreshToken)}`; + } return undefined; } diff --git a/test/storage.test.ts b/test/storage.test.ts index 46030255..93aba856 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { existsSync, promises as fs } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -12,6 +13,7 @@ import { exportNamedBackup, findMatchingAccountIndex, formatStorageErrorHint, + getAccountIdentityKey, getFlaggedAccountsPath, getStoragePath, importAccounts, @@ -95,6 +97,53 @@ describe("storage", () => { expect(error.cause).toBe(cause); }); }); + + describe("account identity keys", () => { + it("prefers accountId and normalized email when both are present", () => { + expect( + getAccountIdentityKey({ + accountId: " acct-123 ", + email: " User@Example.com ", + refreshToken: "secret-token", + }), + ).toBe("account:acct-123::email:user@example.com"); + }); + + it("falls back to accountId when email is missing", () => { + expect( + getAccountIdentityKey({ + accountId: " acct-123 ", + email: " ", + refreshToken: "secret-token", + }), + ).toBe("account:acct-123"); + }); + + it("falls back to normalized email when accountId is missing", () => { + expect( + getAccountIdentityKey({ + accountId: " ", + email: " User@Example.com ", + refreshToken: "secret-token", + }), + ).toBe("email:user@example.com"); + }); + + it("hashes refresh-token-only fallbacks", () => { + const refreshToken = " secret-token "; + const expectedHash = createHash("sha256") + .update(refreshToken.trim()) + .digest("hex"); + const identityKey = getAccountIdentityKey({ + accountId: " ", + email: " ", + refreshToken, + }); + + expect(identityKey).toBe(`refresh:${expectedHash}`); + expect(identityKey).not.toContain(refreshToken.trim()); + }); + }); describe("deduplication", () => { it("preserves activeIndexByFamily when shared accountId entries remain distinct without email", () => { const now = Date.now(); From 222d8b1c3320145d7cca806f237660a5f97cd6d2 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 12:45:48 +0800 Subject: [PATCH 072/376] refactor: extract storage snapshot inspectors --- lib/storage.ts | 128 +++++---------------------- lib/storage/snapshot-inspectors.ts | 133 +++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 106 deletions(-) create mode 100644 lib/storage/snapshot-inspectors.ts diff --git a/lib/storage.ts b/lib/storage.ts index 59095c22..9fd45cf0 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -21,6 +21,10 @@ import { collectNamedBackups, type NamedBackupSummary, } from "./storage/named-backups.js"; +import { + describeAccountsWalSnapshot, + describeFlaggedSnapshot, +} from "./storage/snapshot-inspectors.js"; export { formatStorageErrorHint } from "./storage/error-hints.js"; export { @@ -563,67 +567,6 @@ async function describeAccountSnapshot( } } -async function describeAccountsWalSnapshot( - path: string, -): Promise { - const stats = await statSnapshot(path); - if (!stats.exists) { - return { kind: "accounts-wal", path, exists: false, valid: false }; - } - try { - const raw = await fs.readFile(path, "utf-8"); - const parsed = JSON.parse(raw) as unknown; - if (!isRecord(parsed)) { - return { - kind: "accounts-wal", - path, - exists: true, - valid: false, - bytes: stats.bytes, - mtimeMs: stats.mtimeMs, - }; - } - const entry = parsed as Partial; - if ( - entry.version !== 1 || - typeof entry.content !== "string" || - typeof entry.checksum !== "string" || - computeSha256(entry.content) !== entry.checksum - ) { - return { - kind: "accounts-wal", - path, - exists: true, - valid: false, - bytes: stats.bytes, - mtimeMs: stats.mtimeMs, - }; - } - const { normalized, storedVersion, schemaErrors } = - parseAndNormalizeStorage(JSON.parse(entry.content) as unknown); - return { - kind: "accounts-wal", - path, - exists: true, - valid: !!normalized, - bytes: stats.bytes, - mtimeMs: stats.mtimeMs, - version: typeof storedVersion === "number" ? storedVersion : undefined, - accountCount: normalized?.accounts.length, - schemaErrors: schemaErrors.length > 0 ? schemaErrors : undefined, - }; - } catch { - return { - kind: "accounts-wal", - path, - exists: true, - valid: false, - bytes: stats.bytes, - mtimeMs: stats.mtimeMs, - }; - } -} - async function loadFlaggedAccountsFromPath( path: string, ): Promise { @@ -632,48 +575,6 @@ async function loadFlaggedAccountsFromPath( return normalizeFlaggedStorage(data); } -async function describeFlaggedSnapshot( - path: string, - kind: BackupSnapshotKind, - index?: number, -): Promise { - const stats = await statSnapshot(path); - if (!stats.exists) { - return { kind, path, index, exists: false, valid: false }; - } - try { - const storage = await loadFlaggedAccountsFromPath(path); - return { - kind, - path, - index, - exists: true, - valid: true, - bytes: stats.bytes, - mtimeMs: stats.mtimeMs, - version: storage.version, - flaggedCount: storage.accounts.length, - }; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - log.warn("Failed to inspect flagged snapshot", { - path, - error: String(error), - }); - } - return { - kind, - path, - index, - exists: true, - valid: false, - bytes: stats.bytes, - mtimeMs: stats.mtimeMs, - }; - } -} - type AccountsJournalEntry = { version: 1; createdAt: number; @@ -1361,7 +1262,13 @@ export async function getBackupMetadata(): Promise { await getAccountsBackupRecoveryCandidatesWithDiscovery(storagePath); const accountSnapshots: BackupSnapshotMetadata[] = [ await describeAccountSnapshot(storagePath, "accounts-primary"), - await describeAccountsWalSnapshot(walPath), + await describeAccountsWalSnapshot(walPath, { + statSnapshot, + readFile: fs.readFile, + isRecord, + computeSha256, + parseAndNormalizeStorage, + }), ]; for (const [index, candidate] of accountCandidates.entries()) { const kind: BackupSnapshotKind = @@ -1379,7 +1286,11 @@ export async function getBackupMetadata(): Promise { const flaggedCandidates = await getAccountsBackupRecoveryCandidatesWithDiscovery(flaggedPath); const flaggedSnapshots: BackupSnapshotMetadata[] = [ - await describeFlaggedSnapshot(flaggedPath, "flagged-primary"), + await describeFlaggedSnapshot(flaggedPath, "flagged-primary", { + statSnapshot, + loadFlaggedAccountsFromPath, + logWarn: (message, meta) => log.warn(message, meta), + }), ]; for (const [index, candidate] of flaggedCandidates.entries()) { const kind: BackupSnapshotKind = @@ -1389,7 +1300,12 @@ export async function getBackupMetadata(): Promise { ? "flagged-backup-history" : "flagged-discovered-backup"; flaggedSnapshots.push( - await describeFlaggedSnapshot(candidate, kind, index), + await describeFlaggedSnapshot(candidate, kind, { + index, + statSnapshot, + loadFlaggedAccountsFromPath, + logWarn: (message, meta) => log.warn(message, meta), + }), ); } diff --git a/lib/storage/snapshot-inspectors.ts b/lib/storage/snapshot-inspectors.ts new file mode 100644 index 00000000..f2989ce2 --- /dev/null +++ b/lib/storage/snapshot-inspectors.ts @@ -0,0 +1,133 @@ +import type { FlaggedAccountStorageV1 } from "../storage.js"; +import type { BackupSnapshotMetadata } from "./backup-metadata.js"; + +type SnapshotStats = { + exists: boolean; + bytes?: number; + mtimeMs?: number; +}; + +export async function describeAccountsWalSnapshot( + path: string, + deps: { + statSnapshot: (path: string) => Promise; + readFile: typeof import("node:fs").promises.readFile; + isRecord: (value: unknown) => boolean; + computeSha256: (content: string) => string; + parseAndNormalizeStorage: (data: unknown) => { + normalized: { accounts: unknown[] } | null; + storedVersion?: unknown; + schemaErrors: string[]; + }; + }, +): Promise { + const stats = await deps.statSnapshot(path); + if (!stats.exists) { + return { kind: "accounts-wal", path, exists: false, valid: false }; + } + try { + const raw = await deps.readFile(path, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + if (!deps.isRecord(parsed)) { + return { + kind: "accounts-wal", + path, + exists: true, + valid: false, + bytes: stats.bytes, + mtimeMs: stats.mtimeMs, + }; + } + const entry = parsed as Partial<{ + version: 1; + content: string; + checksum: string; + }>; + if ( + entry.version !== 1 || + typeof entry.content !== "string" || + typeof entry.checksum !== "string" || + deps.computeSha256(entry.content) !== entry.checksum + ) { + return { + kind: "accounts-wal", + path, + exists: true, + valid: false, + bytes: stats.bytes, + mtimeMs: stats.mtimeMs, + }; + } + const { normalized, storedVersion, schemaErrors } = + deps.parseAndNormalizeStorage(JSON.parse(entry.content) as unknown); + return { + kind: "accounts-wal", + path, + exists: true, + valid: !!normalized, + bytes: stats.bytes, + mtimeMs: stats.mtimeMs, + version: typeof storedVersion === "number" ? storedVersion : undefined, + accountCount: normalized?.accounts.length, + schemaErrors: schemaErrors.length > 0 ? schemaErrors : undefined, + }; + } catch { + return { + kind: "accounts-wal", + path, + exists: true, + valid: false, + bytes: stats.bytes, + mtimeMs: stats.mtimeMs, + }; + } +} + +export async function describeFlaggedSnapshot( + path: string, + kind: BackupSnapshotMetadata["kind"], + deps: { + index?: number; + statSnapshot: (path: string) => Promise; + loadFlaggedAccountsFromPath: ( + path: string, + ) => Promise; + logWarn?: (message: string, meta: Record) => void; + }, +): Promise { + const stats = await deps.statSnapshot(path); + if (!stats.exists) { + return { kind, path, index: deps.index, exists: false, valid: false }; + } + try { + const storage = await deps.loadFlaggedAccountsFromPath(path); + return { + kind, + path, + index: deps.index, + exists: true, + valid: true, + bytes: stats.bytes, + mtimeMs: stats.mtimeMs, + version: storage.version, + flaggedCount: storage.accounts.length, + }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + deps.logWarn?.("Failed to inspect flagged snapshot", { + path, + error: String(error), + }); + } + return { + kind, + path, + index: deps.index, + exists: true, + valid: false, + bytes: stats.bytes, + mtimeMs: stats.mtimeMs, + }; + } +} From 8fc053144d6cbbfd33381f535f1ded7e774d72fd Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 12:04:21 +0800 Subject: [PATCH 073/376] refactor: extract storage file path helpers --- lib/storage.ts | 49 +++++++++++++-------------------------- lib/storage/file-paths.ts | 48 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 33 deletions(-) create mode 100644 lib/storage/file-paths.ts diff --git a/lib/storage.ts b/lib/storage.ts index b8a9ea56..28d1b439 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -23,6 +23,17 @@ export { } from "./storage/identity.js"; import { normalizeEmailKey } from "./storage/identity.js"; +import { + ACCOUNTS_BACKUP_SUFFIX, + ACCOUNTS_WAL_SUFFIX, + getFlaggedAccountsPath as buildFlaggedAccountsPath, + getLegacyFlaggedAccountsPath as buildLegacyFlaggedAccountsPath, + getAccountsBackupPath, + getAccountsBackupRecoveryCandidates, + getAccountsWalPath, + getIntentionalResetMarkerPath, + RESET_MARKER_SUFFIX, +} from "./storage/file-paths.js"; import { type AccountMetadataV1, type AccountMetadataV3, @@ -54,12 +65,8 @@ const log = createLogger("storage"); const ACCOUNTS_FILE_NAME = "openai-codex-accounts.json"; const FLAGGED_ACCOUNTS_FILE_NAME = "openai-codex-flagged-accounts.json"; const LEGACY_FLAGGED_ACCOUNTS_FILE_NAME = "openai-codex-blocked-accounts.json"; -const ACCOUNTS_BACKUP_SUFFIX = ".bak"; -const ACCOUNTS_WAL_SUFFIX = ".wal"; -const ACCOUNTS_BACKUP_HISTORY_DEPTH = 3; const BACKUP_COPY_MAX_ATTEMPTS = 5; const BACKUP_COPY_BASE_DELAY_MS = 10; -const RESET_MARKER_SUFFIX = ".reset-intent"; let storageBackupEnabled = true; let lastAccountsSaveTimestamp = 0; @@ -367,25 +374,6 @@ export function setStorageBackupEnabled(enabled: boolean): void { storageBackupEnabled = enabled; } -function getAccountsBackupPath(path: string): string { - return `${path}${ACCOUNTS_BACKUP_SUFFIX}`; -} - -function getAccountsBackupPathAtIndex(path: string, index: number): string { - if (index <= 0) { - return getAccountsBackupPath(path); - } - return `${path}${ACCOUNTS_BACKUP_SUFFIX}.${index}`; -} - -function getAccountsBackupRecoveryCandidates(path: string): string[] { - const candidates: string[] = []; - for (let i = 0; i < ACCOUNTS_BACKUP_HISTORY_DEPTH; i += 1) { - candidates.push(getAccountsBackupPathAtIndex(path, i)); - } - return candidates; -} - async function getAccountsBackupRecoveryCandidatesWithDiscovery( path: string, ): Promise { @@ -425,10 +413,6 @@ async function getAccountsBackupRecoveryCandidatesWithDiscovery( return [...knownCandidates, ...discoveredOrdered]; } -function getAccountsWalPath(path: string): string { - return `${path}${ACCOUNTS_WAL_SUFFIX}`; -} - async function copyFileWithRetry( sourcePath: string, destinationPath: string, @@ -605,10 +589,6 @@ function computeSha256(value: string): string { return createHash("sha256").update(value).digest("hex"); } -function getIntentionalResetMarkerPath(path: string): string { - return `${path}${RESET_MARKER_SUFFIX}`; -} - function createEmptyStorageWithMetadata( restoreEligible: boolean, restoreReason: RestoreReason, @@ -996,11 +976,14 @@ export async function exportNamedBackup( } export function getFlaggedAccountsPath(): string { - return join(dirname(getStoragePath()), FLAGGED_ACCOUNTS_FILE_NAME); + return buildFlaggedAccountsPath(getStoragePath(), FLAGGED_ACCOUNTS_FILE_NAME); } function getLegacyFlaggedAccountsPath(): string { - return join(dirname(getStoragePath()), LEGACY_FLAGGED_ACCOUNTS_FILE_NAME); + return buildLegacyFlaggedAccountsPath( + getStoragePath(), + LEGACY_FLAGGED_ACCOUNTS_FILE_NAME, + ); } async function migrateLegacyProjectStorageIfNeeded( diff --git a/lib/storage/file-paths.ts b/lib/storage/file-paths.ts new file mode 100644 index 00000000..8a626258 --- /dev/null +++ b/lib/storage/file-paths.ts @@ -0,0 +1,48 @@ +import { dirname, join } from "node:path"; + +export const ACCOUNTS_BACKUP_SUFFIX = ".bak"; +export const ACCOUNTS_WAL_SUFFIX = ".wal"; +const ACCOUNTS_BACKUP_HISTORY_DEPTH = 3; +export const RESET_MARKER_SUFFIX = ".reset-intent"; + +export function getAccountsBackupPath(path: string): string { + return `${path}${ACCOUNTS_BACKUP_SUFFIX}`; +} + +export function getAccountsBackupPathAtIndex( + path: string, + index: number, +): string { + if (index <= 0) return getAccountsBackupPath(path); + return `${path}${ACCOUNTS_BACKUP_SUFFIX}.${index}`; +} + +export function getAccountsBackupRecoveryCandidates(path: string): string[] { + const candidates: string[] = []; + for (let i = 0; i < ACCOUNTS_BACKUP_HISTORY_DEPTH; i += 1) { + candidates.push(getAccountsBackupPathAtIndex(path, i)); + } + return candidates; +} + +export function getAccountsWalPath(path: string): string { + return `${path}${ACCOUNTS_WAL_SUFFIX}`; +} + +export function getIntentionalResetMarkerPath(path: string): string { + return `${path}${RESET_MARKER_SUFFIX}`; +} + +export function getFlaggedAccountsPath( + storagePath: string, + fileName: string, +): string { + return join(dirname(storagePath), fileName); +} + +export function getLegacyFlaggedAccountsPath( + storagePath: string, + legacyFileName: string, +): string { + return join(dirname(storagePath), legacyFileName); +} From b241012a31bfccd4b38947b095b3b64154b42259 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 12:10:06 +0800 Subject: [PATCH 074/376] refactor: extract storage path state --- lib/storage.ts | 29 ++++------------------------- lib/storage/path-state.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 25 deletions(-) create mode 100644 lib/storage/path-state.ts diff --git a/lib/storage.ts b/lib/storage.ts index 28d1b439..1440ff57 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -43,6 +43,10 @@ import { migrateV1ToV3, type RateLimitStateV3, } from "./storage/migrations.js"; +import { + getStoragePathState, + setStoragePathState, +} from "./storage/path-state.js"; import { findProjectRoot, getConfigDir, @@ -345,31 +349,6 @@ async function ensureGitignore(storagePath: string): Promise { } } -type StoragePathState = { - currentStoragePath: string | null; - currentLegacyProjectStoragePath: string | null; - currentLegacyWorktreeStoragePath: string | null; - currentProjectRoot: string | null; -}; - -let currentStorageState: StoragePathState = { - currentStoragePath: null, - currentLegacyProjectStoragePath: null, - currentLegacyWorktreeStoragePath: null, - currentProjectRoot: null, -}; - -const storagePathStateContext = new AsyncLocalStorage(); - -function getStoragePathState(): StoragePathState { - return storagePathStateContext.getStore() ?? currentStorageState; -} - -function setStoragePathState(state: StoragePathState): void { - currentStorageState = state; - storagePathStateContext.enterWith(state); -} - export function setStorageBackupEnabled(enabled: boolean): void { storageBackupEnabled = enabled; } diff --git a/lib/storage/path-state.ts b/lib/storage/path-state.ts new file mode 100644 index 00000000..6c708d2c --- /dev/null +++ b/lib/storage/path-state.ts @@ -0,0 +1,26 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +export type StoragePathState = { + currentStoragePath: string | null; + currentLegacyProjectStoragePath: string | null; + currentLegacyWorktreeStoragePath: string | null; + currentProjectRoot: string | null; +}; + +const storagePathStateContext = new AsyncLocalStorage(); + +let currentStorageState: StoragePathState = { + currentStoragePath: null, + currentLegacyProjectStoragePath: null, + currentLegacyWorktreeStoragePath: null, + currentProjectRoot: null, +}; + +export function getStoragePathState(): StoragePathState { + return storagePathStateContext.getStore() ?? currentStorageState; +} + +export function setStoragePathState(state: StoragePathState): void { + currentStorageState = state; + storagePathStateContext.enterWith(state); +} From f3604f58d140f993683ebf303e2c578da78c0810 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 12:48:49 +0800 Subject: [PATCH 075/376] docs: clarify storage path fallback semantics --- lib/storage/path-state.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/storage/path-state.ts b/lib/storage/path-state.ts index 6c708d2c..d0ebb8f4 100644 --- a/lib/storage/path-state.ts +++ b/lib/storage/path-state.ts @@ -17,6 +17,10 @@ let currentStorageState: StoragePathState = { }; export function getStoragePathState(): StoragePathState { + // Keep the last synchronously assigned state as a fallback until enterWith() + // has propagated through the current async chain. This is intentionally a + // best-effort bridge for immediate reads; callers should still set state + // before spawning child work and treat AsyncLocalStorage as the source of truth. return storagePathStateContext.getStore() ?? currentStorageState; } From 8e66d2196bf4ab7665b255747fa3a786a41cc2d8 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 12:52:12 +0800 Subject: [PATCH 076/376] refactor: extract runtime account selection --- index.ts | 98 +++++++------------------------- lib/runtime/account-selection.ts | 70 +++++++++++++++++++++++ 2 files changed, 89 insertions(+), 79 deletions(-) create mode 100644 lib/runtime/account-selection.ts diff --git a/index.ts b/index.ts index ca933145..9e673eb5 100644 --- a/index.ts +++ b/index.ts @@ -33,16 +33,13 @@ import { formatAccountLabel, formatCooldown, formatWaitTime, - getAccountIdCandidates, isCodexCliSyncEnabled, lookupCodexCliTokensByEmail, parseRateLimitReason, resolveRequestAccountId, resolveRuntimeRequestIdentity, sanitizeEmail, - selectBestAccountCandidate, shouldUpdateAccountIdFromToken, - type Workspace, } from "./lib/accounts.js"; import { createAuthorizationFlow, @@ -174,6 +171,10 @@ import { applyFastSessionDefaults } from "./lib/request/request-transformer.js"; import { isEmptyResponse } from "./lib/request/response-handler.js"; import { withStreamingFailover } from "./lib/request/stream-failover.js"; import { addJitter } from "./lib/rotation.js"; +import { + resolveAccountSelection, + type TokenSuccessWithAccount, +} from "./lib/runtime/account-selection.js"; import { createRuntimeMetrics, parseEnvInt, @@ -213,7 +214,6 @@ import { createHashlineReadTool, } from "./lib/tools/hashline-tools.js"; import type { - AccountIdSource, OAuthAuthDetails, RequestBody, TokenResult, @@ -284,71 +284,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const runtimeMetrics: RuntimeMetrics = createRuntimeMetrics(); - type TokenSuccess = Extract; - type TokenSuccessWithAccount = TokenSuccess & { - accountIdOverride?: string; - accountIdSource?: AccountIdSource; - accountLabel?: string; - workspaces?: Workspace[]; - }; - - const resolveAccountSelection = ( - tokens: TokenSuccess, - ): TokenSuccessWithAccount => { - const override = (process.env.CODEX_AUTH_ACCOUNT_ID ?? "").trim(); - if (override) { - const suffix = override.length > 6 ? override.slice(-6) : override; - logInfo( - `Using account override from CODEX_AUTH_ACCOUNT_ID (id:${suffix}).`, - ); - return { - ...tokens, - accountIdOverride: override, - accountIdSource: "manual", - accountLabel: `Override [id:${suffix}]`, - }; - } - - const candidates = getAccountIdCandidates(tokens.access, tokens.idToken); - if (candidates.length === 0) { - return tokens; - } - - // Convert candidates to workspaces - const workspaces: Workspace[] = candidates.map((c) => ({ - id: c.accountId, - name: c.label, - enabled: true, - isDefault: c.isDefault, - })); - - if (candidates.length === 1) { - const [candidate] = candidates; - if (candidate) { - return { - ...tokens, - accountIdOverride: candidate.accountId, - accountIdSource: candidate.source, - accountLabel: candidate.label, - workspaces, - }; - } - } - - // Auto-select the best workspace candidate without prompting. - // This honors org/default/id-token signals and avoids forcing personal token IDs. - const choice = selectBestAccountCandidate(candidates); - if (!choice) return tokens; - - return { - ...tokens, - accountIdOverride: choice.accountId, - accountIdSource: choice.source ?? "token", - accountLabel: choice.label, - workspaces, - }; - }; - const buildManualOAuthFlow = ( pkce: { verifier: string }, url: string, @@ -393,7 +328,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { REDIRECT_URI, ); if (tokens?.type === "success") { - const resolved = resolveAccountSelection(tokens); + const resolved = resolveAccountSelection(tokens, { logInfo }); if (onSuccess) { await onSuccess(resolved); } @@ -3628,13 +3563,16 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { cached.refreshToken.trim() ? cached.refreshToken.trim() : flagged.refreshToken; - const resolved = resolveAccountSelection({ - type: "success", - access: cached.accessToken, - refresh: refreshToken, - expires: cached.expiresAt, - multiAccount: true, - }); + const resolved = resolveAccountSelection( + { + type: "success", + access: cached.accessToken, + refresh: refreshToken, + expires: cached.expiresAt, + multiAccount: true, + }, + { logInfo }, + ); if (!resolved.accountIdOverride && flagged.accountId) { resolved.accountIdOverride = flagged.accountId; resolved.accountIdSource = @@ -3661,7 +3599,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { continue; } - const resolved = resolveAccountSelection(refreshResult); + const resolved = resolveAccountSelection(refreshResult, { + logInfo, + }); if (!resolved.accountIdOverride && flagged.accountId) { resolved.accountIdOverride = flagged.accountId; resolved.accountIdSource = @@ -3943,7 +3883,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { let resolved: TokenSuccessWithAccount | null = null; if (result.type === "success") { - resolved = resolveAccountSelection(result); + resolved = resolveAccountSelection(result, { logInfo }); const email = extractAccountEmail( resolved.access, resolved.idToken, diff --git a/lib/runtime/account-selection.ts b/lib/runtime/account-selection.ts new file mode 100644 index 00000000..078f9dd4 --- /dev/null +++ b/lib/runtime/account-selection.ts @@ -0,0 +1,70 @@ +import { + getAccountIdCandidates, + selectBestAccountCandidate, + type Workspace, +} from "../accounts.js"; +import type { AccountIdSource, TokenResult } from "../types.js"; + +export type TokenSuccess = Extract; + +export type TokenSuccessWithAccount = TokenSuccess & { + accountIdOverride?: string; + accountIdSource?: AccountIdSource; + accountLabel?: string; + workspaces?: Workspace[]; +}; + +export function resolveAccountSelection( + tokens: TokenSuccess, + deps: { logInfo: (message: string) => void }, +): TokenSuccessWithAccount { + const override = (process.env.CODEX_AUTH_ACCOUNT_ID ?? "").trim(); + if (override) { + const suffix = override.length > 6 ? override.slice(-6) : override; + deps.logInfo( + `Using account override from CODEX_AUTH_ACCOUNT_ID (id:${suffix}).`, + ); + return { + ...tokens, + accountIdOverride: override, + accountIdSource: "manual", + accountLabel: `Override [id:${suffix}]`, + }; + } + + const candidates = getAccountIdCandidates(tokens.access, tokens.idToken); + if (candidates.length === 0) { + return tokens; + } + + const workspaces: Workspace[] = candidates.map((candidate) => ({ + id: candidate.accountId, + name: candidate.label, + enabled: true, + isDefault: candidate.isDefault, + })); + + if (candidates.length === 1) { + const [candidate] = candidates; + if (candidate) { + return { + ...tokens, + accountIdOverride: candidate.accountId, + accountIdSource: candidate.source, + accountLabel: candidate.label, + workspaces, + }; + } + } + + const choice = selectBestAccountCandidate(candidates); + if (!choice) return tokens; + + return { + ...tokens, + accountIdOverride: choice.accountId, + accountIdSource: choice.source ?? "token", + accountLabel: choice.label, + workspaces, + }; +} From 2215db9486030e3dd99bf264dce782846d580255 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 12:58:41 +0800 Subject: [PATCH 077/376] refactor: extract manual oauth flow helper --- index.ts | 142 +++++++++++-------------------- lib/runtime/manual-oauth-flow.ts | 74 ++++++++++++++++ 2 files changed, 122 insertions(+), 94 deletions(-) create mode 100644 lib/runtime/manual-oauth-flow.ts diff --git a/index.ts b/index.ts index 9e673eb5..95f62fad 100644 --- a/index.ts +++ b/index.ts @@ -44,7 +44,6 @@ import { import { createAuthorizationFlow, exchangeAuthorizationCode, - parseAuthorizationInput, REDIRECT_URI, redactOAuthUrlForLog, } from "./lib/auth/auth.js"; @@ -175,6 +174,7 @@ import { resolveAccountSelection, type TokenSuccessWithAccount, } from "./lib/runtime/account-selection.js"; +import { buildManualOAuthFlow } from "./lib/runtime/manual-oauth-flow.js"; import { createRuntimeMetrics, parseEnvInt, @@ -284,60 +284,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const runtimeMetrics: RuntimeMetrics = createRuntimeMetrics(); - const buildManualOAuthFlow = ( - pkce: { verifier: string }, - url: string, - expectedState: string, - onSuccess?: (tokens: TokenSuccessWithAccount) => Promise, - ) => ({ - url, - method: "code" as const, - instructions: AUTH_LABELS.INSTRUCTIONS_MANUAL, - validate: (input: string): string | undefined => { - const parsed = parseAuthorizationInput(input); - if (!parsed.code) { - return `No authorization code found. Paste the full callback URL (e.g., ${REDIRECT_URI}?code=...)`; - } - if (!parsed.state) { - return "Missing OAuth state. Paste the full callback URL including both code and state parameters."; - } - if (parsed.state !== expectedState) { - return "OAuth state mismatch. Restart login and paste the callback URL generated for this login attempt."; - } - return undefined; - }, - callback: async (input: string) => { - const parsed = parseAuthorizationInput(input); - if (!parsed.code || !parsed.state) { - return { - type: "failed" as const, - reason: "invalid_response" as const, - message: "Missing authorization code or OAuth state", - }; - } - if (parsed.state !== expectedState) { - return { - type: "failed" as const, - reason: "invalid_response" as const, - message: "OAuth state mismatch. Restart login and try again.", - }; - } - const tokens = await exchangeAuthorizationCode( - parsed.code, - pkce.verifier, - REDIRECT_URI, - ); - if (tokens?.type === "success") { - const resolved = resolveAccountSelection(tokens, { logInfo }); - if (onSuccess) { - await onSuccess(resolved); - } - return resolved; - } - return tokens?.type === "failed" ? tokens : { type: "failed" as const }; - }, - }); - const runOAuthFlow = async ( forceNewLogin: boolean = false, ): Promise => { @@ -3848,26 +3794,30 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { if (useManualMode) { const { pkce, state, url } = await createAuthorizationFlow(); - return buildManualOAuthFlow(pkce, url, state, async (tokens) => { - try { - await persistAccountPool([tokens], startFresh); - invalidateAccountManagerCache(); - } catch (err) { - const storagePath = getStoragePath(); - const errorCode = - (err as NodeJS.ErrnoException)?.code || "UNKNOWN"; - const hint = - err instanceof StorageError - ? err.hint - : formatStorageErrorHint(err, storagePath); - logError( - `[${PLUGIN_NAME}] Failed to persist account: [${errorCode}] ${(err as Error)?.message ?? String(err)}`, - ); - await showToast(hint, "error", { - title: "Account Persistence Failed", - duration: 10000, - }); - } + return buildManualOAuthFlow(pkce, url, state, { + instructions: AUTH_LABELS.INSTRUCTIONS_MANUAL, + logInfo, + onSuccess: async (tokens: TokenSuccessWithAccount) => { + try { + await persistAccountPool([tokens], startFresh); + invalidateAccountManagerCache(); + } catch (err) { + const storagePath = getStoragePath(); + const errorCode = + (err as NodeJS.ErrnoException)?.code || "UNKNOWN"; + const hint = + err instanceof StorageError + ? err.hint + : formatStorageErrorHint(err, storagePath); + logError( + `[${PLUGIN_NAME}] Failed to persist account: [${errorCode}] ${(err as Error)?.message ?? String(err)}`, + ); + await showToast(hint, "error", { + title: "Account Persistence Failed", + duration: 10000, + }); + } + }, }); } @@ -4039,25 +3989,29 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { applyAccountStorageScope(manualPluginConfig); const { pkce, state, url } = await createAuthorizationFlow(); - return buildManualOAuthFlow(pkce, url, state, async (tokens) => { - try { - await persistAccountPool([tokens], false); - } catch (err) { - const storagePath = getStoragePath(); - const errorCode = - (err as NodeJS.ErrnoException)?.code || "UNKNOWN"; - const hint = - err instanceof StorageError - ? err.hint - : formatStorageErrorHint(err, storagePath); - logError( - `[${PLUGIN_NAME}] Failed to persist account: [${errorCode}] ${(err as Error)?.message ?? String(err)}`, - ); - await showToast(hint, "error", { - title: "Account Persistence Failed", - duration: 10000, - }); - } + return buildManualOAuthFlow(pkce, url, state, { + instructions: AUTH_LABELS.INSTRUCTIONS_MANUAL, + logInfo, + onSuccess: async (tokens: TokenSuccessWithAccount) => { + try { + await persistAccountPool([tokens], false); + } catch (err) { + const storagePath = getStoragePath(); + const errorCode = + (err as NodeJS.ErrnoException)?.code || "UNKNOWN"; + const hint = + err instanceof StorageError + ? err.hint + : formatStorageErrorHint(err, storagePath); + logError( + `[${PLUGIN_NAME}] Failed to persist account: [${errorCode}] ${(err as Error)?.message ?? String(err)}`, + ); + await showToast(hint, "error", { + title: "Account Persistence Failed", + duration: 10000, + }); + } + }, }); }, }, diff --git a/lib/runtime/manual-oauth-flow.ts b/lib/runtime/manual-oauth-flow.ts new file mode 100644 index 00000000..55666b12 --- /dev/null +++ b/lib/runtime/manual-oauth-flow.ts @@ -0,0 +1,74 @@ +import { + exchangeAuthorizationCode, + parseAuthorizationInput, + REDIRECT_URI, +} from "../auth/auth.js"; +import type { TokenResult } from "../types.js"; +import { + resolveAccountSelection, + type TokenSuccessWithAccount, +} from "./account-selection.js"; + +export function buildManualOAuthFlow( + pkce: { verifier: string }, + url: string, + expectedState: string, + deps: { + instructions: string; + logInfo: (message: string) => void; + onSuccess?: (tokens: TokenSuccessWithAccount) => Promise; + }, +) { + return { + url, + method: "code" as const, + instructions: deps.instructions, + validate: (input: string): string | undefined => { + const parsed = parseAuthorizationInput(input); + if (!parsed.code) { + return `No authorization code found. Paste the full callback URL (e.g., ${REDIRECT_URI}?code=...)`; + } + if (!parsed.state) { + return "Missing OAuth state. Paste the full callback URL including both code and state parameters."; + } + if (parsed.state !== expectedState) { + return "OAuth state mismatch. Restart login and paste the callback URL generated for this login attempt."; + } + return undefined; + }, + callback: async ( + input: string, + ): Promise => { + const parsed = parseAuthorizationInput(input); + if (!parsed.code || !parsed.state) { + return { + type: "failed" as const, + reason: "invalid_response" as const, + message: "Missing authorization code or OAuth state", + }; + } + if (parsed.state !== expectedState) { + return { + type: "failed" as const, + reason: "invalid_response" as const, + message: "OAuth state mismatch. Restart login and try again.", + }; + } + const tokens = await exchangeAuthorizationCode( + parsed.code, + pkce.verifier, + REDIRECT_URI, + ); + if (tokens?.type === "success") { + const resolved = resolveAccountSelection(tokens, { + logInfo: deps.logInfo, + }); + if (deps.onSuccess) { + await deps.onSuccess(resolved); + } + return resolved; + } + return tokens?.type === "failed" ? tokens : { type: "failed" as const }; + }, + }; +} From 1bccc763a451a0e11a46ab0aaa52b9eb6c814a74 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 13:02:12 +0800 Subject: [PATCH 078/376] refactor: extract oauth browser flow --- index.ts | 62 +++++-------------------------- lib/runtime/oauth-browser-flow.ts | 55 +++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 53 deletions(-) create mode 100644 lib/runtime/oauth-browser-flow.ts diff --git a/index.ts b/index.ts index 95f62fad..6fd871a0 100644 --- a/index.ts +++ b/index.ts @@ -41,17 +41,8 @@ import { sanitizeEmail, shouldUpdateAccountIdFromToken, } from "./lib/accounts.js"; -import { - createAuthorizationFlow, - exchangeAuthorizationCode, - REDIRECT_URI, - redactOAuthUrlForLog, -} from "./lib/auth/auth.js"; -import { - isBrowserLaunchSuppressed, - openBrowserUrl, -} from "./lib/auth/browser.js"; -import { startLocalOAuthServer } from "./lib/auth/server.js"; +import { createAuthorizationFlow } from "./lib/auth/auth.js"; +import { isBrowserLaunchSuppressed } from "./lib/auth/browser.js"; import { checkAndNotify } from "./lib/auto-update-checker.js"; import { CapabilityPolicyStore } from "./lib/capability-policy.js"; import { promptAddAnotherAccount, promptLoginMode } from "./lib/cli.js"; @@ -183,6 +174,7 @@ import { type RuntimeMetrics, sanitizeResponseHeadersForLog, } from "./lib/runtime/metrics.js"; +import { runOAuthBrowserFlow } from "./lib/runtime/oauth-browser-flow.js"; import { SessionAffinityStore } from "./lib/session-affinity.js"; import { registerCleanup } from "./lib/shutdown.js"; import { @@ -286,50 +278,14 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const runOAuthFlow = async ( forceNewLogin: boolean = false, - ): Promise => { - const { pkce, state, url } = await createAuthorizationFlow({ + ): Promise => + runOAuthBrowserFlow({ forceNewLogin, + manualModeLabel: AUTH_LABELS.OAUTH_MANUAL, + logInfo, + logDebug: (message) => logDebug(`[${PLUGIN_NAME}] ${message}`), + logWarn: (message) => logWarn(`[${PLUGIN_NAME}] ${message}`), }); - logInfo(`OAuth URL: ${redactOAuthUrlForLog(url)}`); - - let serverInfo: Awaited> | null = - null; - try { - serverInfo = await startLocalOAuthServer({ state }); - } catch (err) { - logDebug( - `[${PLUGIN_NAME}] Failed to start OAuth server: ${(err as Error)?.message ?? String(err)}`, - ); - serverInfo = null; - } - openBrowserUrl(url); - - if (!serverInfo || !serverInfo.ready) { - serverInfo?.close(); - const message = - `\n[${PLUGIN_NAME}] OAuth callback server failed to start. ` + - `Please retry with "${AUTH_LABELS.OAUTH_MANUAL}".\n`; - logWarn(message); - return { type: "failed" as const }; - } - - const result = await serverInfo.waitForCode(state); - serverInfo.close(); - - if (!result) { - return { - type: "failed" as const, - reason: "unknown" as const, - message: "OAuth callback timeout or cancelled", - }; - } - - return await exchangeAuthorizationCode( - result.code, - pkce.verifier, - REDIRECT_URI, - ); - }; const persistAccountPool = async ( results: TokenSuccessWithAccount[], diff --git a/lib/runtime/oauth-browser-flow.ts b/lib/runtime/oauth-browser-flow.ts new file mode 100644 index 00000000..70592993 --- /dev/null +++ b/lib/runtime/oauth-browser-flow.ts @@ -0,0 +1,55 @@ +import { + createAuthorizationFlow, + exchangeAuthorizationCode, + REDIRECT_URI, + redactOAuthUrlForLog, +} from "../auth/auth.js"; +import { openBrowserUrl } from "../auth/browser.js"; +import { startLocalOAuthServer } from "../auth/server.js"; +import type { TokenResult } from "../types.js"; + +export async function runOAuthBrowserFlow(deps: { + forceNewLogin?: boolean; + manualModeLabel: string; + logInfo: (message: string) => void; + logDebug: (message: string) => void; + logWarn: (message: string) => void; +}): Promise { + const { pkce, state, url } = await createAuthorizationFlow({ + forceNewLogin: deps.forceNewLogin ?? false, + }); + deps.logInfo(`OAuth URL: ${redactOAuthUrlForLog(url)}`); + + let serverInfo: Awaited> | null = + null; + try { + serverInfo = await startLocalOAuthServer({ state }); + } catch (err) { + deps.logDebug( + `Failed to start OAuth server: ${(err as Error)?.message ?? String(err)}`, + ); + serverInfo = null; + } + openBrowserUrl(url); + + if (!serverInfo || !serverInfo.ready) { + serverInfo?.close(); + deps.logWarn( + `\nOAuth callback server failed to start. Please retry with "${deps.manualModeLabel}".\n`, + ); + return { type: "failed" as const }; + } + + const result = await serverInfo.waitForCode(state); + serverInfo.close(); + + if (!result) { + return { + type: "failed" as const, + reason: "unknown" as const, + message: "OAuth callback timeout or cancelled", + }; + } + + return exchangeAuthorizationCode(result.code, pkce.verifier, REDIRECT_URI); +} From d1b793516d883fe75a3b3820040a3f7a65ea7f5a Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 13:05:34 +0800 Subject: [PATCH 079/376] fix: align repair command review followups --- lib/codex-manager/repair-commands.ts | 60 ++++++++----- test/repair-commands.test.ts | 129 +++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 21 deletions(-) diff --git a/lib/codex-manager/repair-commands.ts b/lib/codex-manager/repair-commands.ts index 915df46d..d0ea3d05 100644 --- a/lib/codex-manager/repair-commands.ts +++ b/lib/codex-manager/repair-commands.ts @@ -681,13 +681,17 @@ function applyDoctorFixes( ): { changed: boolean; actions: DoctorFixAction[] } { let changed = false; const actions: DoctorFixAction[] = []; - - if (normalizeDoctorIndexes(storage)) { - changed = true; + const recordActiveIndexAction = () => { + if (actions.some((action) => action.key === "active-index")) return; actions.push({ key: "active-index", message: "Normalized active account indexes", }); + }; + + if (normalizeDoctorIndexes(storage)) { + changed = true; + recordActiveIndexAction(); } const seenRefreshTokens = new Map(); @@ -749,6 +753,7 @@ function applyDoctorFixes( if (normalizeDoctorIndexes(storage)) { changed = true; + recordActiveIndexAction(); } return { changed, actions }; @@ -1400,6 +1405,7 @@ export async function runFix( } const changed = accountStorageChanged; + const anyChanged = accountStorageChanged || quotaCacheChanged; if (options.json) { if (!options.dryRun && workingQuotaCache && quotaCacheChanged) { @@ -1508,9 +1514,9 @@ export async function runFix( await saveQuotaCache(workingQuotaCache); } - if (changed && options.dryRun) { + if (anyChanged && options.dryRun) { console.log(`\n${deps.stylePromptText("Preview only: no changes were saved.", "warning")}`); - } else if (changed) { + } else if (anyChanged) { console.log(`\n${deps.stylePromptText("Saved updates.", "success")}`); } else { console.log(`\n${deps.stylePromptText("No changes were needed.", "muted")}`); @@ -1686,6 +1692,8 @@ export async function runDoctor( const loadedStorage = await loadAccounts(); const storage = loadedStorage ? structuredClone(loadedStorage) : loadedStorage; + const storageForChecks = + options.fix && storage ? structuredClone(storage) : storage; let fixChanged = false; let storageFixChanged = false; let structuralFixActions: DoctorFixAction[] = []; @@ -1699,12 +1707,12 @@ export async function runDoctor( expiresAt: number | undefined; idToken?: string; } | null = null; - if (options.fix && storage && storage.accounts.length > 0) { - const fixed = applyDoctorFixes(storage, deps); + if (options.fix && storageForChecks && storageForChecks.accounts.length > 0) { + const fixed = applyDoctorFixes(storageForChecks, deps); storageFixChanged = fixed.changed; structuralFixActions = fixed.actions; } - if (!storage || storage.accounts.length === 0) { + if (!storageForChecks || storageForChecks.accounts.length === 0) { addCheck({ key: "accounts", severity: "warn", @@ -1714,11 +1722,12 @@ export async function runDoctor( addCheck({ key: "accounts", severity: "ok", - message: `Loaded ${storage.accounts.length} account(s)`, + message: `Loaded ${storageForChecks.accounts.length} account(s)`, }); - const activeIndex = deps.resolveActiveIndex(storage, "codex"); - const activeExists = activeIndex >= 0 && activeIndex < storage.accounts.length; + const activeIndex = deps.resolveActiveIndex(storageForChecks, "codex"); + const activeExists = + activeIndex >= 0 && activeIndex < storageForChecks.accounts.length; addCheck({ key: "active-index", severity: activeExists ? "ok" : "error", @@ -1727,19 +1736,22 @@ export async function runDoctor( : "Active index is out of range", }); - const disabledCount = storage.accounts.filter((a) => a.enabled === false).length; + const disabledCount = storageForChecks.accounts.filter( + (a) => a.enabled === false, + ).length; addCheck({ key: "enabled-accounts", - severity: disabledCount >= storage.accounts.length ? "error" : "ok", + severity: + disabledCount >= storageForChecks.accounts.length ? "error" : "ok", message: - disabledCount >= storage.accounts.length + disabledCount >= storageForChecks.accounts.length ? "All accounts are disabled" - : `${storage.accounts.length - disabledCount} enabled / ${disabledCount} disabled`, + : `${storageForChecks.accounts.length - disabledCount} enabled / ${disabledCount} disabled`, }); const seenRefreshTokens = new Set(); let duplicateTokenCount = 0; - for (const account of storage.accounts) { + for (const account of storageForChecks.accounts) { const token = getDoctorRefreshTokenKey(account.refreshToken); if (!token) continue; if (seenRefreshTokens.has(token)) { @@ -1761,7 +1773,7 @@ export async function runDoctor( let duplicateEmailCount = 0; let placeholderEmailCount = 0; let likelyInvalidRefreshTokenCount = 0; - for (const account of storage.accounts) { + for (const account of storageForChecks.accounts) { if (deps.hasLikelyInvalidRefreshToken(account.refreshToken)) { likelyInvalidRefreshTokenCount += 1; } @@ -1798,7 +1810,7 @@ export async function runDoctor( const now = Date.now(); const forecastResults = evaluateForecastAccounts( - storage.accounts.map((account, index) => ({ + storageForChecks.accounts.map((account, index) => ({ index, account, isCurrent: index === activeIndex, @@ -1825,7 +1837,7 @@ export async function runDoctor( } if (activeExists) { - const activeAccount = storage.accounts[activeIndex]; + const activeAccount = storageForChecks.accounts[activeIndex]; const managerActiveEmail = sanitizeEmail(activeAccount?.email); const managerActiveAccountId = activeAccount?.accountId; const codexActiveEmail = @@ -1936,7 +1948,13 @@ export async function runDoctor( } } - if (options.fix && storage && storage.accounts.length > 0 && storageFixChanged && !options.dryRun) { + if ( + options.fix + && storageForChecks + && storageForChecks.accounts.length > 0 + && storageFixChanged + && !options.dryRun + ) { await withAccountStorageTransaction(async (loadedStorage, persist) => { const nextStorage = loadedStorage ? structuredClone(loadedStorage) @@ -2016,7 +2034,7 @@ export async function runDoctor( const fixActions = [...structuralFixActions, ...supplementalFixActions]; - if (options.fix && storage && storage.accounts.length > 0) { + if (options.fix && storageForChecks && storageForChecks.accounts.length > 0) { fixChanged = storageFixChanged || fixActions.length > 0; addCheck({ key: "auto-fix", diff --git a/test/repair-commands.test.ts b/test/repair-commands.test.ts index f352a94f..513e0ce5 100644 --- a/test/repair-commands.test.ts +++ b/test/repair-commands.test.ts @@ -442,6 +442,54 @@ describe("repair-commands direct deps coverage", () => { }); }); + it("runFix reports saved updates for quota-cache-only live changes in display mode", async () => { + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + loadQuotaCacheMock.mockResolvedValueOnce({ + byAccountId: {}, + byEmail: {}, + }); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + accounts: [ + { + email: "quota@example.com", + refreshToken: "quota-refresh", + accessToken: "quota-access", + expiresAt: Date.now() + 60_000, + accountId: "quota-account", + accountIdSource: "manual" as const, + enabled: true, + }, + ], + activeIndex: 0, + activeIndexByFamily: {}, + }); + fetchCodexQuotaSnapshotMock.mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + }); + + const exitCode = await runFix( + ["--live"], + createDeps({ + hasUsableAccessToken: () => true, + updateQuotaCacheForAccount: () => true, + }), + ); + + expect(exitCode).toBe(0); + expect(withAccountStorageTransactionMock).not.toHaveBeenCalled(); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + const output = consoleSpy.mock.calls + .map((call) => call.map((value) => String(value)).join(" ")) + .join("\n"); + expect(output).toContain("Saved updates."); + expect(output).not.toContain("No changes were needed."); + }); + it("runFix does not double-count a live probe failure followed by refresh fallback", async () => { loadQuotaCacheMock.mockResolvedValueOnce({ byAccountId: {}, @@ -676,6 +724,87 @@ describe("repair-commands direct deps coverage", () => { ); }); + it("runDoctor records active-index fixes when normalization changes the snapshot", async () => { + const now = Date.now(); + let persistedAccountStorage: unknown; + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + accounts: [ + { + email: "doctor@example.com", + refreshToken: "doctor-refresh", + accessToken: "doctor-access", + expiresAt: now + 60_000, + accountId: "doctor-account", + accountIdSource: "manual" as const, + enabled: true, + }, + ], + activeIndex: 7, + activeIndexByFamily: { + codex: 7, + "codex-max": 7, + "gpt-5-codex": 7, + }, + }); + withAccountStorageTransactionMock.mockImplementation(async (handler) => + handler( + { + version: 3, + accounts: [ + { + email: "doctor@example.com", + refreshToken: "doctor-refresh", + accessToken: "doctor-access", + expiresAt: now + 60_000, + accountId: "doctor-account", + accountIdSource: "manual" as const, + enabled: true, + }, + ], + activeIndex: 7, + activeIndexByFamily: { + codex: 7, + "codex-max": 7, + "gpt-5-codex": 7, + }, + }, + async (nextStorage: unknown) => { + persistedAccountStorage = nextStorage; + }, + ), + ); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const exitCode = await runDoctor( + ["--json", "--fix"], + createDeps({ + hasUsableAccessToken: () => true, + }), + ); + + expect(exitCode).toBe(0); + expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(persistedAccountStorage).toMatchObject({ + activeIndex: 0, + activeIndexByFamily: { + codex: 0, + "codex-max": 0, + "gpt-5-codex": 0, + }, + }); + const payload = JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")) as { + fix: { + changed: boolean; + actions: Array<{ key: string }>; + }; + }; + expect(payload.fix.changed).toBe(true); + expect(payload.fix.actions).toContainEqual( + expect.objectContaining({ key: "active-index" }), + ); + }); + it("runDoctor keeps the prescan snapshot unchanged when the transaction is already fixed", async () => { const now = Date.now(); let persistedAccountStorage: unknown; From e01437ef91695b497988ac75f4d4e675d0af89c2 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 13:05:48 +0800 Subject: [PATCH 080/376] fix: align storage error review followups --- lib/errors.ts | 8 +++----- lib/storage/error-hints.ts | 14 +++++++------- test/storage.test.ts | 7 ------- 3 files changed, 10 insertions(+), 19 deletions(-) diff --git a/lib/errors.ts b/lib/errors.ts index 334ea591..d1b95e03 100644 --- a/lib/errors.ts +++ b/lib/errors.ts @@ -168,8 +168,8 @@ export class CodexRateLimitError extends CodexError { /** * Storage-specific error with a filesystem code, target path, and user-facing hint. */ -export class StorageError extends Error { - readonly code: string; +export class StorageError extends CodexError { + override readonly name = "StorageError"; readonly path: string; readonly hint: string; @@ -180,9 +180,7 @@ export class StorageError extends Error { hint: string, cause?: Error, ) { - super(message, { cause }); - this.name = "StorageError"; - this.code = code; + super(message, { code, cause }); this.path = path; this.hint = hint; } diff --git a/lib/storage/error-hints.ts b/lib/storage/error-hints.ts index 03cc4c86..e5c79532 100644 --- a/lib/storage/error-hints.ts +++ b/lib/storage/error-hints.ts @@ -1,8 +1,12 @@ import { StorageError } from "../errors.js"; -export function formatStorageErrorHint(error: unknown, path: string): string { +function extractErrorCode(error: unknown): string { const err = error as NodeJS.ErrnoException; - const code = err?.code || "UNKNOWN"; + return err?.code || "UNKNOWN"; +} + +export function formatStorageErrorHint(error: unknown, path: string): string { + const code = extractErrorCode(error); const isWindows = process.platform === "win32"; switch (code) { @@ -15,8 +19,6 @@ export function formatStorageErrorHint(error: unknown, path: string): string { return `File is locked at ${path}. The file may be open in another program. Close any editors or processes accessing it.`; case "ENOSPC": return `Disk is full. Free up space and try again. Path: ${path}`; - case "EEMPTY": - return `File written but is empty. This may indicate a disk or filesystem issue. Path: ${path}`; default: return isWindows ? `Failed to write to ${path}. Check folder permissions and ensure path contains no special characters.` @@ -29,11 +31,9 @@ export function toStorageError( error: unknown, path: string, ): StorageError { - const err = error as NodeJS.ErrnoException; - const code = err?.code || "UNKNOWN"; return new StorageError( message, - code, + extractErrorCode(error), path, formatStorageErrorHint(error, path), error instanceof Error ? error : undefined, diff --git a/test/storage.test.ts b/test/storage.test.ts index 46030255..7ea802b7 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1239,13 +1239,6 @@ describe("storage", () => { expect(hint).toContain("Disk is full"); }); - it("should return empty file hint for EEMPTY", () => { - const err = { code: "EEMPTY" } as NodeJS.ErrnoException; - const hint = formatStorageErrorHint(err, testPath); - - expect(hint).toContain("empty"); - }); - it("should return generic hint for unknown error codes", () => { const err = { code: "UNKNOWN_CODE" } as NodeJS.ErrnoException; const hint = formatStorageErrorHint(err, testPath); From 9373f0542c3ebb745c4afdc8bb9b216551035f23 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 13:05:48 +0800 Subject: [PATCH 081/376] docs: explain hashed refresh identity fallback --- lib/storage/identity.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/storage/identity.ts b/lib/storage/identity.ts index d6ab4b70..42c301e9 100644 --- a/lib/storage/identity.ts +++ b/lib/storage/identity.ts @@ -62,6 +62,8 @@ export function getAccountIdentityKey( if (ref.accountId) return `account:${ref.accountId}`; if (ref.emailKey) return `email:${ref.emailKey}`; if (ref.refreshToken) { + // Legacy refresh-only identity keys embedded raw tokens. Hashing preserves + // deterministic fallback matching without exposing token material in logs. return `refresh:${hashRefreshTokenKey(ref.refreshToken)}`; } return undefined; From 7ccf2174e642b274a2f3a74734aaa5146fca64e8 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 13:06:20 +0800 Subject: [PATCH 082/376] refactor: extract runtime account state helpers --- index.ts | 55 ++++-------------------------------- lib/runtime/account-state.ts | 52 ++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 50 deletions(-) create mode 100644 lib/runtime/account-state.ts diff --git a/index.ts b/index.ts index 6fd871a0..06a794ba 100644 --- a/index.ts +++ b/index.ts @@ -165,6 +165,11 @@ import { resolveAccountSelection, type TokenSuccessWithAccount, } from "./lib/runtime/account-selection.js"; +import { + formatRateLimitEntry, + getRateLimitResetTimeForFamily, + resolveActiveIndex, +} from "./lib/runtime/account-state.js"; import { buildManualOAuthFlow } from "./lib/runtime/manual-oauth-flow.js"; import { createRuntimeMetrics, @@ -483,22 +488,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } }; - const resolveActiveIndex = ( - storage: { - activeIndex: number; - activeIndexByFamily?: Partial>; - accounts: unknown[]; - }, - family: ModelFamily = "codex", - ): number => { - const total = storage.accounts.length; - if (total === 0) return 0; - const rawCandidate = - storage.activeIndexByFamily?.[family] ?? storage.activeIndex; - const raw = Number.isFinite(rawCandidate) ? rawCandidate : 0; - return Math.max(0, Math.min(raw, total - 1)); - }; - const hydrateEmails = async ( storage: AccountStorageV3 | null, ): Promise => { @@ -571,40 +560,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return storage; }; - const getRateLimitResetTimeForFamily = ( - account: { rateLimitResetTimes?: Record }, - now: number, - family: ModelFamily, - ): number | null => { - const times = account.rateLimitResetTimes; - if (!times) return null; - - let minReset: number | null = null; - const prefix = `${family}:`; - for (const [key, value] of Object.entries(times)) { - if (typeof value !== "number") continue; - if (value <= now) continue; - if (key !== family && !key.startsWith(prefix)) continue; - if (minReset === null || value < minReset) { - minReset = value; - } - } - - return minReset; - }; - - const formatRateLimitEntry = ( - account: { rateLimitResetTimes?: Record }, - now: number, - family: ModelFamily = "codex", - ): string | null => { - const resetAt = getRateLimitResetTimeForFamily(account, now, family); - if (typeof resetAt !== "number") return null; - const remaining = resetAt - now; - if (remaining <= 0) return null; - return `resets in ${formatWaitTime(remaining)}`; - }; - const applyUiRuntimeFromConfig = ( pluginConfig: ReturnType, ): UiRuntimeOptions => { diff --git a/lib/runtime/account-state.ts b/lib/runtime/account-state.ts new file mode 100644 index 00000000..7f6350e1 --- /dev/null +++ b/lib/runtime/account-state.ts @@ -0,0 +1,52 @@ +import { formatWaitTime } from "../accounts.js"; +import type { ModelFamily } from "../prompts/codex.js"; + +export function resolveActiveIndex( + storage: { + activeIndex: number; + activeIndexByFamily?: Partial>; + accounts: unknown[]; + }, + family: ModelFamily = "codex", +): number { + const total = storage.accounts.length; + if (total === 0) return 0; + const rawCandidate = + storage.activeIndexByFamily?.[family] ?? storage.activeIndex; + const raw = Number.isFinite(rawCandidate) ? rawCandidate : 0; + return Math.max(0, Math.min(raw, total - 1)); +} + +export function getRateLimitResetTimeForFamily( + account: { rateLimitResetTimes?: Record }, + now: number, + family: ModelFamily, +): number | null { + const times = account.rateLimitResetTimes; + if (!times) return null; + + let minReset: number | null = null; + const prefix = `${family}:`; + for (const [key, value] of Object.entries(times)) { + if (typeof value !== "number") continue; + if (value <= now) continue; + if (key !== family && !key.startsWith(prefix)) continue; + if (minReset === null || value < minReset) { + minReset = value; + } + } + + return minReset; +} + +export function formatRateLimitEntry( + account: { rateLimitResetTimes?: Record }, + now: number, + family: ModelFamily = "codex", +): string | null { + const resetAt = getRateLimitResetTimeForFamily(account, now, family); + if (typeof resetAt !== "number") return null; + const remaining = resetAt - now; + if (remaining <= 0) return null; + return `resets in ${formatWaitTime(remaining)}`; +} From f6dd8e3c906d54de11a1a350566df70b0bdd866e Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 13:13:09 +0800 Subject: [PATCH 083/376] refactor: extract runtime account pool persistence --- index.ts | 195 +++--------------------------------- lib/runtime/account-pool.ts | 192 +++++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+), 181 deletions(-) create mode 100644 lib/runtime/account-pool.ts diff --git a/index.ts b/index.ts index 06a794ba..fee58f84 100644 --- a/index.ts +++ b/index.ts @@ -122,7 +122,6 @@ import { getCodexInstructions, getModelFamily, MODEL_FAMILIES, - type ModelFamily, prewarmCodexInstructions, } from "./lib/prompts/codex.js"; import { prewarmHostCodexPrompt } from "./lib/prompts/host-codex-prompt.js"; @@ -161,6 +160,7 @@ import { applyFastSessionDefaults } from "./lib/request/request-transformer.js"; import { isEmptyResponse } from "./lib/request/response-handler.js"; import { withStreamingFailover } from "./lib/request/stream-failover.js"; import { addJitter } from "./lib/rotation.js"; +import { persistAccountPool } from "./lib/runtime/account-pool.js"; import { resolveAccountSelection, type TokenSuccessWithAccount, @@ -292,182 +292,18 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { logWarn: (message) => logWarn(`[${PLUGIN_NAME}] ${message}`), }); - const persistAccountPool = async ( + const persistAccounts = async ( results: TokenSuccessWithAccount[], replaceAll: boolean = false, - ): Promise => { - if (results.length === 0) return; - await withAccountStorageTransaction(async (loadedStorage, persist) => { - const now = Date.now(); - const stored = replaceAll ? null : loadedStorage; - const accounts = stored?.accounts ? [...stored.accounts] : []; - - for (const result of results) { - const accountId = - result.accountIdOverride ?? extractAccountId(result.access); - const accountIdSource = accountId - ? (result.accountIdSource ?? - (result.accountIdOverride ? "manual" : "token")) - : undefined; - const accountLabel = result.accountLabel; - const accountEmail = sanitizeEmail( - extractAccountEmail(result.access, result.idToken), - ); - const existingIndex = findMatchingAccountIndex( - accounts, - { - accountId, - email: accountEmail, - refreshToken: result.refresh, - }, - { - allowUniqueAccountIdFallbackWithoutEmail: true, - }, - ); - - if (existingIndex === undefined) { - const initialWorkspaceIndex = - result.workspaces && result.workspaces.length > 0 - ? (() => { - if (accountId) { - const matchingWorkspaceIndex = result.workspaces.findIndex( - (workspace) => workspace.id === accountId, - ); - if (matchingWorkspaceIndex >= 0) { - return matchingWorkspaceIndex; - } - } - const firstEnabledWorkspaceIndex = - result.workspaces.findIndex( - (workspace) => workspace.enabled !== false, - ); - return firstEnabledWorkspaceIndex >= 0 - ? firstEnabledWorkspaceIndex - : 0; - })() - : undefined; - accounts.push({ - accountId, - accountIdSource, - accountLabel, - email: accountEmail, - refreshToken: result.refresh, - accessToken: result.access, - expiresAt: result.expires, - addedAt: now, - lastUsed: now, - workspaces: result.workspaces, - currentWorkspaceIndex: initialWorkspaceIndex, - }); - continue; - } - - const existing = accounts[existingIndex]; - if (!existing) continue; - - const nextEmail = accountEmail ?? sanitizeEmail(existing.email); - const nextAccountId = accountId ?? existing.accountId; - const nextAccountIdSource = accountId - ? (accountIdSource ?? existing.accountIdSource) - : existing.accountIdSource; - const nextAccountLabel = accountLabel ?? existing.accountLabel; - // Preserve tracked workspace state when auth refreshes do not return workspace metadata. - const mergedWorkspaces = result.workspaces - ? result.workspaces.map((newWs) => { - const existingWs = existing.workspaces?.find( - (w) => w.id === newWs.id, - ); - return existingWs - ? { - ...newWs, - enabled: existingWs.enabled, - disabledAt: existingWs.disabledAt, - } - : newWs; - }) - : existing.workspaces; - const currentWorkspaceId = - existing.workspaces?.[ - typeof existing.currentWorkspaceIndex === "number" - ? existing.currentWorkspaceIndex - : 0 - ]?.id; - const nextCurrentWorkspaceIndex = - mergedWorkspaces && mergedWorkspaces.length > 0 - ? (() => { - if (currentWorkspaceId) { - const matchingWorkspaceIndex = mergedWorkspaces.findIndex( - (workspace) => workspace.id === currentWorkspaceId, - ); - if (matchingWorkspaceIndex >= 0) { - return matchingWorkspaceIndex; - } - } - const defaultWorkspaceIndex = mergedWorkspaces.findIndex( - (workspace) => workspace.isDefault === true, - ); - if (defaultWorkspaceIndex >= 0) { - return defaultWorkspaceIndex; - } - const firstEnabledWorkspaceIndex = mergedWorkspaces.findIndex( - (workspace) => workspace.enabled !== false, - ); - return firstEnabledWorkspaceIndex >= 0 - ? firstEnabledWorkspaceIndex - : 0; - })() - : existing.currentWorkspaceIndex; - accounts[existingIndex] = { - ...existing, - accountId: nextAccountId, - accountIdSource: nextAccountIdSource, - accountLabel: nextAccountLabel, - email: nextEmail, - refreshToken: result.refresh, - accessToken: result.access, - expiresAt: result.expires, - lastUsed: now, - workspaces: mergedWorkspaces, - currentWorkspaceIndex: nextCurrentWorkspaceIndex, - }; - } - - if (accounts.length === 0) return; - - const activeIndex = replaceAll - ? 0 - : typeof stored?.activeIndex === "number" && - Number.isFinite(stored.activeIndex) - ? stored.activeIndex - : 0; - - const clampedActiveIndex = Math.max( - 0, - Math.min(activeIndex, accounts.length - 1), - ); - const activeIndexByFamily: Partial> = {}; - for (const family of MODEL_FAMILIES) { - const storedFamilyIndex = stored?.activeIndexByFamily?.[family]; - const rawFamilyIndex = replaceAll - ? 0 - : typeof storedFamilyIndex === "number" && - Number.isFinite(storedFamilyIndex) - ? storedFamilyIndex - : clampedActiveIndex; - activeIndexByFamily[family] = Math.max( - 0, - Math.min(Math.floor(rawFamilyIndex), accounts.length - 1), - ); - } - - await persist({ - version: 3, - accounts, - activeIndex: clampedActiveIndex, - activeIndexByFamily, - }); + ): Promise => + persistAccountPool(results, replaceAll, { + withAccountStorageTransaction, + extractAccountId, + extractAccountEmail, + sanitizeEmail, + findMatchingAccountIndex, + MODEL_FAMILIES, }); - }; const showToast = async ( message: string, @@ -3485,7 +3321,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } if (restored.length > 0) { - await persistAccountPool(restored, false); + await persistAccounts(restored, false); invalidateAccountManagerCache(); } @@ -3710,7 +3546,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { logInfo, onSuccess: async (tokens: TokenSuccessWithAccount) => { try { - await persistAccountPool([tokens], startFresh); + await persistAccounts([tokens], startFresh); invalidateAccountManagerCache(); } catch (err) { const storagePath = getStoragePath(); @@ -3814,10 +3650,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { try { const isFirstAccount = accounts.length === 1; - await persistAccountPool( - [resolved], - isFirstAccount && startFresh, - ); + await persistAccounts([resolved], isFirstAccount && startFresh); invalidateAccountManagerCache(); } catch (err) { const storagePath = getStoragePath(); @@ -3905,7 +3738,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { logInfo, onSuccess: async (tokens: TokenSuccessWithAccount) => { try { - await persistAccountPool([tokens], false); + await persistAccounts([tokens], false); } catch (err) { const storagePath = getStoragePath(); const errorCode = diff --git a/lib/runtime/account-pool.ts b/lib/runtime/account-pool.ts new file mode 100644 index 00000000..247cb0ba --- /dev/null +++ b/lib/runtime/account-pool.ts @@ -0,0 +1,192 @@ +import type { ModelFamily } from "../prompts/codex.js"; +import type { AccountMetadataV3, AccountStorageV3 } from "../storage.js"; +import type { TokenSuccessWithAccount } from "./account-selection.js"; + +export interface PersistAccountPoolDeps { + withAccountStorageTransaction: ( + callback: ( + loadedStorage: AccountStorageV3 | null, + persist: (nextStorage: AccountStorageV3) => Promise, + ) => Promise, + ) => Promise; + extractAccountId: (accessToken: string | undefined) => string | undefined; + extractAccountEmail: ( + accessToken: string | undefined, + idToken?: string | undefined, + ) => string | undefined; + sanitizeEmail: (email: string | undefined) => string | undefined; + findMatchingAccountIndex: ( + accounts: Array<{ + accountId?: string; + email?: string; + refreshToken?: string; + }>, + target: { accountId?: string; email?: string; refreshToken?: string }, + options: { allowUniqueAccountIdFallbackWithoutEmail?: boolean }, + ) => number | undefined; + MODEL_FAMILIES: readonly ModelFamily[]; + getNow?: () => number; +} + +export async function persistAccountPool( + results: TokenSuccessWithAccount[], + replaceAll: boolean, + deps: PersistAccountPoolDeps, +): Promise { + if (results.length === 0) return; + await deps.withAccountStorageTransaction(async (loadedStorage, persist) => { + const now = deps.getNow?.() ?? Date.now(); + const stored = replaceAll ? null : loadedStorage; + const accounts = stored?.accounts ? [...stored.accounts] : []; + + for (const result of results) { + const accountId = + result.accountIdOverride ?? deps.extractAccountId(result.access); + const accountIdSource = accountId + ? (result.accountIdSource ?? + (result.accountIdOverride ? "manual" : "token")) + : undefined; + const accountLabel = result.accountLabel; + const accountEmail = deps.sanitizeEmail( + deps.extractAccountEmail(result.access, result.idToken), + ); + const existingIndex = deps.findMatchingAccountIndex( + accounts, + { accountId, email: accountEmail, refreshToken: result.refresh }, + { allowUniqueAccountIdFallbackWithoutEmail: true }, + ); + + if (existingIndex === undefined) { + const initialWorkspaceIndex = + result.workspaces && result.workspaces.length > 0 + ? (() => { + if (accountId) { + const matchingWorkspaceIndex = result.workspaces.findIndex( + (workspace) => workspace.id === accountId, + ); + if (matchingWorkspaceIndex >= 0) + return matchingWorkspaceIndex; + } + const firstEnabledWorkspaceIndex = result.workspaces.findIndex( + (workspace) => workspace.enabled !== false, + ); + return firstEnabledWorkspaceIndex >= 0 + ? firstEnabledWorkspaceIndex + : 0; + })() + : undefined; + accounts.push({ + accountId, + accountIdSource, + accountLabel, + email: accountEmail, + refreshToken: result.refresh, + accessToken: result.access, + expiresAt: result.expires, + addedAt: now, + lastUsed: now, + workspaces: result.workspaces, + currentWorkspaceIndex: initialWorkspaceIndex, + } satisfies AccountMetadataV3); + continue; + } + + const existing = accounts[existingIndex]; + if (!existing) continue; + const nextEmail = accountEmail ?? deps.sanitizeEmail(existing.email); + const nextAccountId = accountId ?? existing.accountId; + const nextAccountIdSource = accountId + ? (accountIdSource ?? existing.accountIdSource) + : existing.accountIdSource; + const nextAccountLabel = accountLabel ?? existing.accountLabel; + const mergedWorkspaces = result.workspaces + ? result.workspaces.map((newWs) => { + const existingWs = existing.workspaces?.find( + (workspace) => workspace.id === newWs.id, + ); + return existingWs + ? { + ...newWs, + enabled: existingWs.enabled, + disabledAt: existingWs.disabledAt, + } + : newWs; + }) + : existing.workspaces; + const currentWorkspaceId = + existing.workspaces?.[ + typeof existing.currentWorkspaceIndex === "number" + ? existing.currentWorkspaceIndex + : 0 + ]?.id; + const nextCurrentWorkspaceIndex = + mergedWorkspaces && mergedWorkspaces.length > 0 + ? (() => { + if (currentWorkspaceId) { + const matchingWorkspaceIndex = mergedWorkspaces.findIndex( + (workspace) => workspace.id === currentWorkspaceId, + ); + if (matchingWorkspaceIndex >= 0) return matchingWorkspaceIndex; + } + const defaultWorkspaceIndex = mergedWorkspaces.findIndex( + (workspace) => workspace.isDefault === true, + ); + if (defaultWorkspaceIndex >= 0) return defaultWorkspaceIndex; + const firstEnabledWorkspaceIndex = mergedWorkspaces.findIndex( + (workspace) => workspace.enabled !== false, + ); + return firstEnabledWorkspaceIndex >= 0 + ? firstEnabledWorkspaceIndex + : 0; + })() + : existing.currentWorkspaceIndex; + + accounts[existingIndex] = { + ...existing, + accountId: nextAccountId, + accountIdSource: nextAccountIdSource, + accountLabel: nextAccountLabel, + email: nextEmail, + refreshToken: result.refresh, + accessToken: result.access, + expiresAt: result.expires, + lastUsed: now, + workspaces: mergedWorkspaces, + currentWorkspaceIndex: nextCurrentWorkspaceIndex, + }; + } + + if (accounts.length === 0) return; + const activeIndex = replaceAll + ? 0 + : typeof stored?.activeIndex === "number" && + Number.isFinite(stored.activeIndex) + ? stored.activeIndex + : 0; + const clampedActiveIndex = Math.max( + 0, + Math.min(activeIndex, accounts.length - 1), + ); + const activeIndexByFamily: Partial> = {}; + for (const family of deps.MODEL_FAMILIES) { + const storedFamilyIndex = stored?.activeIndexByFamily?.[family]; + const rawFamilyIndex = replaceAll + ? 0 + : typeof storedFamilyIndex === "number" && + Number.isFinite(storedFamilyIndex) + ? storedFamilyIndex + : clampedActiveIndex; + activeIndexByFamily[family] = Math.max( + 0, + Math.min(Math.floor(rawFamilyIndex), accounts.length - 1), + ); + } + + await persist({ + version: 3, + accounts, + activeIndex: clampedActiveIndex, + activeIndexByFamily, + }); + }); +} From c478968a8cc551db6ac3fc26e88b08c59034b53c Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 13:15:28 +0800 Subject: [PATCH 084/376] refactor: extract runtime toast helper --- index.ts | 16 ++-------------- lib/runtime/toast.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 14 deletions(-) create mode 100644 lib/runtime/toast.ts diff --git a/index.ts b/index.ts index fee58f84..cd3eb9fb 100644 --- a/index.ts +++ b/index.ts @@ -180,6 +180,7 @@ import { sanitizeResponseHeadersForLog, } from "./lib/runtime/metrics.js"; import { runOAuthBrowserFlow } from "./lib/runtime/oauth-browser-flow.js"; +import { showRuntimeToast } from "./lib/runtime/toast.js"; import { SessionAffinityStore } from "./lib/session-affinity.js"; import { registerCleanup } from "./lib/shutdown.js"; import { @@ -309,20 +310,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { message: string, variant: "info" | "success" | "warning" | "error" = "success", options?: { title?: string; duration?: number }, - ): Promise => { - try { - await client.tui.showToast({ - body: { - message, - variant, - ...(options?.title && { title: options.title }), - ...(options?.duration && { duration: options.duration }), - }, - }); - } catch { - // Ignore when TUI is not available. - } - }; + ): Promise => showRuntimeToast(client, message, variant, options); const hydrateEmails = async ( storage: AccountStorageV3 | null, diff --git a/lib/runtime/toast.ts b/lib/runtime/toast.ts new file mode 100644 index 00000000..d4c19e18 --- /dev/null +++ b/lib/runtime/toast.ts @@ -0,0 +1,30 @@ +export async function showRuntimeToast( + client: { + tui?: { + showToast?: (payload: { + body: { + message: string; + variant: "info" | "success" | "warning" | "error"; + title?: string; + duration?: number; + }; + }) => Promise; + }; + }, + message: string, + variant: "info" | "success" | "warning" | "error" = "success", + options?: { title?: string; duration?: number }, +): Promise { + try { + await client.tui?.showToast?.({ + body: { + message, + variant, + ...(options?.title && { title: options.title }), + ...(options?.duration && { duration: options.duration }), + }, + }); + } catch { + // Ignore when TUI is not available. + } +} From 14fbed954c8fdbfc10d2ce9de7f4c4d09390b46e Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 14:50:49 +0800 Subject: [PATCH 085/376] refactor: extract runtime account storage scope --- index.ts | 33 +++++++++++++++++---------------- lib/runtime/account-scope.ts | 24 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 16 deletions(-) create mode 100644 lib/runtime/account-scope.ts diff --git a/index.ts b/index.ts index cd3eb9fb..4ae89643 100644 --- a/index.ts +++ b/index.ts @@ -161,6 +161,7 @@ import { isEmptyResponse } from "./lib/request/response-handler.js"; import { withStreamingFailover } from "./lib/request/stream-failover.js"; import { addJitter } from "./lib/rotation.js"; import { persistAccountPool } from "./lib/runtime/account-pool.js"; +import { applyAccountStorageScope } from "./lib/runtime/account-scope.js"; import { resolveAccountSelection, type TokenSuccessWithAccount, @@ -436,24 +437,24 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } }; - const applyAccountStorageScope = ( + const applyStorageScope = ( pluginConfig: ReturnType, - ): void => { - const perProjectAccounts = getPerProjectAccounts(pluginConfig); - setStorageBackupEnabled(getStorageBackupEnabled(pluginConfig)); - if (isCodexCliSyncEnabled()) { - if (perProjectAccounts && !perProjectStorageWarningShown) { + ): void => + applyAccountStorageScope(pluginConfig, { + getPerProjectAccounts, + getStorageBackupEnabled, + isCodexCliSyncEnabled, + setStorageBackupEnabled, + setStoragePath, + getCwd: () => process.cwd(), + warnPerProjectSyncConflict: () => { + if (perProjectStorageWarningShown) return; perProjectStorageWarningShown = true; logWarn( `[${PLUGIN_NAME}] CODEX_AUTH_PER_PROJECT_ACCOUNTS is ignored while Codex CLI sync is enabled. Using global account storage.`, ); - } - setStoragePath(null); - return; - } - - setStoragePath(perProjectAccounts ? process.cwd() : null); - }; + }, + }); const ensureLiveAccountSync = async ( pluginConfig: ReturnType, @@ -665,7 +666,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const auth = await getAuth(); const pluginConfig = loadPluginConfig(); applyUiRuntimeFromConfig(pluginConfig); - applyAccountStorageScope(pluginConfig); + applyStorageScope(pluginConfig); ensureSessionAffinity(pluginConfig); ensureRefreshGuardian(pluginConfig); applyPreemptiveQuotaSettings(pluginConfig); @@ -2529,7 +2530,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { authorize: async (inputs?: Record) => { const authPluginConfig = loadPluginConfig(); applyUiRuntimeFromConfig(authPluginConfig); - applyAccountStorageScope(authPluginConfig); + applyStorageScope(authPluginConfig); const accounts: TokenSuccessWithAccount[] = []; const noBrowser = @@ -3718,7 +3719,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { // Must happen BEFORE persistAccountPool to ensure correct storage location const manualPluginConfig = loadPluginConfig(); applyUiRuntimeFromConfig(manualPluginConfig); - applyAccountStorageScope(manualPluginConfig); + applyStorageScope(manualPluginConfig); const { pkce, state, url } = await createAuthorizationFlow(); return buildManualOAuthFlow(pkce, url, state, { diff --git a/lib/runtime/account-scope.ts b/lib/runtime/account-scope.ts new file mode 100644 index 00000000..a9cb9f13 --- /dev/null +++ b/lib/runtime/account-scope.ts @@ -0,0 +1,24 @@ +export function applyAccountStorageScope( + pluginConfig: TConfig, + deps: { + getPerProjectAccounts: (config: TConfig) => boolean; + getStorageBackupEnabled: (config: TConfig) => boolean; + isCodexCliSyncEnabled: () => boolean; + setStorageBackupEnabled: (enabled: boolean) => void; + setStoragePath: (path: string | null) => void; + getCwd: () => string; + warnPerProjectSyncConflict: () => void; + }, +): void { + const perProjectAccounts = deps.getPerProjectAccounts(pluginConfig); + deps.setStorageBackupEnabled(deps.getStorageBackupEnabled(pluginConfig)); + if (deps.isCodexCliSyncEnabled()) { + if (perProjectAccounts) { + deps.warnPerProjectSyncConflict(); + } + deps.setStoragePath(null); + return; + } + + deps.setStoragePath(perProjectAccounts ? deps.getCwd() : null); +} From c7f80903a6e462f2ef978aee3b23d8047235a79a Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 14:55:45 +0800 Subject: [PATCH 086/376] refactor: extract runtime live sync helper --- index.ts | 67 ++++++++++------------------------- lib/runtime/live-sync.ts | 76 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 49 deletions(-) create mode 100644 lib/runtime/live-sync.ts diff --git a/index.ts b/index.ts index 4ae89643..2a70bcf4 100644 --- a/index.ts +++ b/index.ts @@ -171,6 +171,7 @@ import { getRateLimitResetTimeForFamily, resolveActiveIndex, } from "./lib/runtime/account-state.js"; +import { ensureRuntimeLiveAccountSync } from "./lib/runtime/live-sync.js"; import { buildManualOAuthFlow } from "./lib/runtime/manual-oauth-flow.js"; import { createRuntimeMetrics, @@ -460,55 +461,23 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { pluginConfig: ReturnType, authFallback?: OAuthAuthDetails, ): Promise => { - if (!getLiveAccountSync(pluginConfig)) { - if (liveAccountSync) { - liveAccountSync.stop(); - liveAccountSync = null; - liveAccountSyncPath = null; - } - return; - } - - const targetPath = getStoragePath(); - if (!liveAccountSync) { - liveAccountSync = new LiveAccountSync( - async () => { - await reloadAccountManagerFromDisk(authFallback); - }, - { - debounceMs: getLiveAccountSyncDebounceMs(pluginConfig), - pollIntervalMs: getLiveAccountSyncPollMs(pluginConfig), - }, - ); - registerCleanup(() => { - liveAccountSync?.stop(); - }); - } - - if (liveAccountSyncPath !== targetPath) { - let switched = false; - for (let attempt = 0; attempt < 3; attempt += 1) { - try { - await liveAccountSync.syncToPath(targetPath); - liveAccountSyncPath = targetPath; - switched = true; - break; - } catch (error) { - const code = (error as NodeJS.ErrnoException | undefined)?.code; - if (code !== "EBUSY" && code !== "EPERM") { - throw error; - } - await new Promise((resolve) => - setTimeout(resolve, 25 * 2 ** attempt), - ); - } - } - if (!switched) { - logWarn( - `[${PLUGIN_NAME}] Live account sync path switch failed due to transient filesystem locks; keeping previous watcher.`, - ); - } - } + const ensured = await ensureRuntimeLiveAccountSync({ + pluginConfig, + authFallback, + getLiveAccountSync, + getStoragePath, + currentSync: liveAccountSync, + currentPath: liveAccountSyncPath, + createSync: (onChange, options) => new LiveAccountSync(onChange, options), + reloadAccountManagerFromDisk, + getLiveAccountSyncDebounceMs, + getLiveAccountSyncPollMs, + registerCleanup, + logWarn, + pluginName: PLUGIN_NAME, + }); + liveAccountSync = ensured.sync; + liveAccountSyncPath = ensured.path; }; const ensureRefreshGuardian = ( diff --git a/lib/runtime/live-sync.ts b/lib/runtime/live-sync.ts new file mode 100644 index 00000000..3ab38769 --- /dev/null +++ b/lib/runtime/live-sync.ts @@ -0,0 +1,76 @@ +import type { OAuthAuthDetails } from "../types.js"; + +export interface LiveSyncController { + stop(): void; + syncToPath(path: string): Promise; +} + +export async function ensureRuntimeLiveAccountSync< + TConfig, + TSync extends LiveSyncController, +>(deps: { + pluginConfig: TConfig; + authFallback?: OAuthAuthDetails; + getLiveAccountSync: (config: TConfig) => boolean; + getStoragePath: () => string; + currentSync: TSync | null; + currentPath: string | null; + createSync: ( + onChange: () => Promise, + options: { debounceMs: number; pollIntervalMs: number }, + ) => TSync; + reloadAccountManagerFromDisk: ( + authFallback?: OAuthAuthDetails, + ) => Promise; + getLiveAccountSyncDebounceMs: (config: TConfig) => number; + getLiveAccountSyncPollMs: (config: TConfig) => number; + registerCleanup: (cleanup: () => void) => void; + logWarn: (message: string) => void; + pluginName: string; +}): Promise<{ sync: TSync | null; path: string | null }> { + if (!deps.getLiveAccountSync(deps.pluginConfig)) { + deps.currentSync?.stop(); + return { sync: null, path: null }; + } + + const targetPath = deps.getStoragePath(); + let sync = deps.currentSync; + if (!sync) { + sync = deps.createSync( + async () => { + await deps.reloadAccountManagerFromDisk(deps.authFallback); + }, + { + debounceMs: deps.getLiveAccountSyncDebounceMs(deps.pluginConfig), + pollIntervalMs: deps.getLiveAccountSyncPollMs(deps.pluginConfig), + }, + ); + deps.registerCleanup(() => { + sync?.stop(); + }); + } + + let nextPath = deps.currentPath; + if (nextPath !== targetPath) { + let switched = false; + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + await sync.syncToPath(targetPath); + nextPath = targetPath; + switched = true; + break; + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code !== "EBUSY" && code !== "EPERM") throw error; + await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); + } + } + if (!switched) { + deps.logWarn( + `[${deps.pluginName}] Live account sync path switch failed due to transient filesystem locks; keeping previous watcher.`, + ); + } + } + + return { sync, path: nextPath }; +} From dcd26cb30118385e69216b8c3acca70065aa3402 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 14:58:11 +0800 Subject: [PATCH 087/376] refactor: extract runtime refresh guardian helper --- index.ts | 41 +++++++++++++-------------------- lib/runtime/refresh-guardian.ts | 41 +++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 25 deletions(-) create mode 100644 lib/runtime/refresh-guardian.ts diff --git a/index.ts b/index.ts index 2a70bcf4..53585af7 100644 --- a/index.ts +++ b/index.ts @@ -182,6 +182,7 @@ import { sanitizeResponseHeadersForLog, } from "./lib/runtime/metrics.js"; import { runOAuthBrowserFlow } from "./lib/runtime/oauth-browser-flow.js"; +import { ensureRuntimeRefreshGuardian } from "./lib/runtime/refresh-guardian.js"; import { showRuntimeToast } from "./lib/runtime/toast.js"; import { SessionAffinityStore } from "./lib/session-affinity.js"; import { registerCleanup } from "./lib/shutdown.js"; @@ -483,32 +484,22 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const ensureRefreshGuardian = ( pluginConfig: ReturnType, ): void => { - if (!getProactiveRefreshGuardian(pluginConfig)) { - if (refreshGuardian) { - refreshGuardian.stop(); - refreshGuardian = null; - refreshGuardianConfigKey = null; - } - return; - } - - const intervalMs = getProactiveRefreshIntervalMs(pluginConfig); - const bufferMs = getProactiveRefreshBufferMs(pluginConfig); - const configKey = `${intervalMs}:${bufferMs}`; - if (refreshGuardian && refreshGuardianConfigKey === configKey) return; - - if (refreshGuardian) { - refreshGuardian.stop(); - } - refreshGuardian = new RefreshGuardian(() => cachedAccountManager, { - intervalMs, - bufferMs, - }); - refreshGuardianConfigKey = configKey; - refreshGuardian.start(); - registerCleanup(() => { - refreshGuardian?.stop(); + const ensured = ensureRuntimeRefreshGuardian({ + pluginConfig, + getProactiveRefreshGuardian, + currentGuardian: refreshGuardian, + currentConfigKey: refreshGuardianConfigKey, + getProactiveRefreshIntervalMs, + getProactiveRefreshBufferMs, + createGuardian: ({ intervalMs, bufferMs }) => + new RefreshGuardian(() => cachedAccountManager, { + intervalMs, + bufferMs, + }), + registerCleanup, }); + refreshGuardian = ensured.guardian; + refreshGuardianConfigKey = ensured.configKey; }; const ensureSessionAffinity = ( diff --git a/lib/runtime/refresh-guardian.ts b/lib/runtime/refresh-guardian.ts new file mode 100644 index 00000000..d57f479f --- /dev/null +++ b/lib/runtime/refresh-guardian.ts @@ -0,0 +1,41 @@ +export interface RefreshGuardianController { + stop(): void; + start(): void; +} + +export function ensureRuntimeRefreshGuardian< + TConfig, + TGuardian extends RefreshGuardianController, +>(deps: { + pluginConfig: TConfig; + getProactiveRefreshGuardian: (config: TConfig) => boolean; + currentGuardian: TGuardian | null; + currentConfigKey: string | null; + getProactiveRefreshIntervalMs: (config: TConfig) => number; + getProactiveRefreshBufferMs: (config: TConfig) => number; + createGuardian: (options: { + intervalMs: number; + bufferMs: number; + }) => TGuardian; + registerCleanup: (cleanup: () => void) => void; +}): { guardian: TGuardian | null; configKey: string | null } { + if (!deps.getProactiveRefreshGuardian(deps.pluginConfig)) { + deps.currentGuardian?.stop(); + return { guardian: null, configKey: null }; + } + + const intervalMs = deps.getProactiveRefreshIntervalMs(deps.pluginConfig); + const bufferMs = deps.getProactiveRefreshBufferMs(deps.pluginConfig); + const configKey = `${intervalMs}:${bufferMs}`; + if (deps.currentGuardian && deps.currentConfigKey === configKey) { + return { guardian: deps.currentGuardian, configKey: deps.currentConfigKey }; + } + + deps.currentGuardian?.stop(); + const guardian = deps.createGuardian({ intervalMs, bufferMs }); + guardian.start(); + deps.registerCleanup(() => { + guardian.stop(); + }); + return { guardian, configKey }; +} From 08e44fec54abfb516148af03f73c316d6edcd5e0 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 15:00:57 +0800 Subject: [PATCH 088/376] refactor: extract runtime session affinity helper --- index.ts | 23 +++++++++++------------ lib/runtime/session-affinity.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 12 deletions(-) create mode 100644 lib/runtime/session-affinity.ts diff --git a/index.ts b/index.ts index 53585af7..3ff9fc6a 100644 --- a/index.ts +++ b/index.ts @@ -183,6 +183,7 @@ import { } from "./lib/runtime/metrics.js"; import { runOAuthBrowserFlow } from "./lib/runtime/oauth-browser-flow.js"; import { ensureRuntimeRefreshGuardian } from "./lib/runtime/refresh-guardian.js"; +import { ensureRuntimeSessionAffinity } from "./lib/runtime/session-affinity.js"; import { showRuntimeToast } from "./lib/runtime/toast.js"; import { SessionAffinityStore } from "./lib/session-affinity.js"; import { registerCleanup } from "./lib/shutdown.js"; @@ -505,18 +506,16 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const ensureSessionAffinity = ( pluginConfig: ReturnType, ): void => { - if (!getSessionAffinity(pluginConfig)) { - sessionAffinityStore = null; - sessionAffinityConfigKey = null; - return; - } - - const ttlMs = getSessionAffinityTtlMs(pluginConfig); - const maxEntries = getSessionAffinityMaxEntries(pluginConfig); - const configKey = `${ttlMs}:${maxEntries}`; - if (sessionAffinityStore && sessionAffinityConfigKey === configKey) return; - sessionAffinityStore = new SessionAffinityStore({ ttlMs, maxEntries }); - sessionAffinityConfigKey = configKey; + const ensured = ensureRuntimeSessionAffinity({ + pluginConfig, + getSessionAffinity, + currentStore: sessionAffinityStore, + currentConfigKey: sessionAffinityConfigKey, + getSessionAffinityTtlMs, + getSessionAffinityMaxEntries, + }); + sessionAffinityStore = ensured.store; + sessionAffinityConfigKey = ensured.configKey; }; const applyPreemptiveQuotaSettings = ( diff --git a/lib/runtime/session-affinity.ts b/lib/runtime/session-affinity.ts new file mode 100644 index 00000000..d932be01 --- /dev/null +++ b/lib/runtime/session-affinity.ts @@ -0,0 +1,26 @@ +import { SessionAffinityStore } from "../session-affinity.js"; + +export function ensureRuntimeSessionAffinity(deps: { + pluginConfig: TConfig; + getSessionAffinity: (config: TConfig) => boolean; + currentStore: SessionAffinityStore | null; + currentConfigKey: string | null; + getSessionAffinityTtlMs: (config: TConfig) => number; + getSessionAffinityMaxEntries: (config: TConfig) => number; +}): { store: SessionAffinityStore | null; configKey: string | null } { + if (!deps.getSessionAffinity(deps.pluginConfig)) { + return { store: null, configKey: null }; + } + + const ttlMs = deps.getSessionAffinityTtlMs(deps.pluginConfig); + const maxEntries = deps.getSessionAffinityMaxEntries(deps.pluginConfig); + const configKey = `${ttlMs}:${maxEntries}`; + if (deps.currentStore && deps.currentConfigKey === configKey) { + return { store: deps.currentStore, configKey: deps.currentConfigKey }; + } + + return { + store: new SessionAffinityStore({ ttlMs, maxEntries }), + configKey, + }; +} From 549e95b232e003cd2e7501a53e4552399807459a Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 15:03:17 +0800 Subject: [PATCH 089/376] refactor: extract runtime preemptive quota helper --- index.ts | 14 +++++++------- lib/runtime/preemptive-quota.ts | 24 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) create mode 100644 lib/runtime/preemptive-quota.ts diff --git a/index.ts b/index.ts index 3ff9fc6a..b844ac4a 100644 --- a/index.ts +++ b/index.ts @@ -182,6 +182,7 @@ import { sanitizeResponseHeadersForLog, } from "./lib/runtime/metrics.js"; import { runOAuthBrowserFlow } from "./lib/runtime/oauth-browser-flow.js"; +import { applyRuntimePreemptiveQuotaSettings } from "./lib/runtime/preemptive-quota.js"; import { ensureRuntimeRefreshGuardian } from "./lib/runtime/refresh-guardian.js"; import { ensureRuntimeSessionAffinity } from "./lib/runtime/session-affinity.js"; import { showRuntimeToast } from "./lib/runtime/toast.js"; @@ -521,13 +522,12 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const applyPreemptiveQuotaSettings = ( pluginConfig: ReturnType, ): void => { - preemptiveQuotaScheduler.configure({ - enabled: getPreemptiveQuotaEnabled(pluginConfig), - remainingPercentThresholdPrimary: - getPreemptiveQuotaRemainingPercent5h(pluginConfig), - remainingPercentThresholdSecondary: - getPreemptiveQuotaRemainingPercent7d(pluginConfig), - maxDeferralMs: getPreemptiveQuotaMaxDeferralMs(pluginConfig), + applyRuntimePreemptiveQuotaSettings(pluginConfig, { + configure: (options) => preemptiveQuotaScheduler.configure(options), + getPreemptiveQuotaEnabled, + getPreemptiveQuotaRemainingPercent5h, + getPreemptiveQuotaRemainingPercent7d, + getPreemptiveQuotaMaxDeferralMs, }); }; diff --git a/lib/runtime/preemptive-quota.ts b/lib/runtime/preemptive-quota.ts new file mode 100644 index 00000000..561ca11f --- /dev/null +++ b/lib/runtime/preemptive-quota.ts @@ -0,0 +1,24 @@ +export function applyRuntimePreemptiveQuotaSettings( + pluginConfig: TConfig, + deps: { + configure: (options: { + enabled: boolean; + remainingPercentThresholdPrimary: number; + remainingPercentThresholdSecondary: number; + maxDeferralMs: number; + }) => void; + getPreemptiveQuotaEnabled: (config: TConfig) => boolean; + getPreemptiveQuotaRemainingPercent5h: (config: TConfig) => number; + getPreemptiveQuotaRemainingPercent7d: (config: TConfig) => number; + getPreemptiveQuotaMaxDeferralMs: (config: TConfig) => number; + }, +): void { + deps.configure({ + enabled: deps.getPreemptiveQuotaEnabled(pluginConfig), + remainingPercentThresholdPrimary: + deps.getPreemptiveQuotaRemainingPercent5h(pluginConfig), + remainingPercentThresholdSecondary: + deps.getPreemptiveQuotaRemainingPercent7d(pluginConfig), + maxDeferralMs: deps.getPreemptiveQuotaMaxDeferralMs(pluginConfig), + }); +} From 17a879702da1d0486841410fd51aba9d22bd9c85 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 15:06:28 +0800 Subject: [PATCH 090/376] refactor: extract runtime ui settings helper --- index.ts | 10 ++++++---- lib/runtime/ui-runtime.ts | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 lib/runtime/ui-runtime.ts diff --git a/index.ts b/index.ts index b844ac4a..3f636c61 100644 --- a/index.ts +++ b/index.ts @@ -186,6 +186,7 @@ import { applyRuntimePreemptiveQuotaSettings } from "./lib/runtime/preemptive-qu import { ensureRuntimeRefreshGuardian } from "./lib/runtime/refresh-guardian.js"; import { ensureRuntimeSessionAffinity } from "./lib/runtime/session-affinity.js"; import { showRuntimeToast } from "./lib/runtime/toast.js"; +import { applyRuntimeUiOptions } from "./lib/runtime/ui-runtime.js"; import { SessionAffinityStore } from "./lib/session-affinity.js"; import { registerCleanup } from "./lib/shutdown.js"; import { @@ -392,10 +393,11 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const applyUiRuntimeFromConfig = ( pluginConfig: ReturnType, ): UiRuntimeOptions => { - return setUiRuntimeOptions({ - v2Enabled: getCodexTuiV2(pluginConfig), - colorProfile: getCodexTuiColorProfile(pluginConfig), - glyphMode: getCodexTuiGlyphMode(pluginConfig), + return applyRuntimeUiOptions(pluginConfig, { + setUiRuntimeOptions, + getCodexTuiV2, + getCodexTuiColorProfile, + getCodexTuiGlyphMode, }); }; diff --git a/lib/runtime/ui-runtime.ts b/lib/runtime/ui-runtime.ts new file mode 100644 index 00000000..0bf07fbe --- /dev/null +++ b/lib/runtime/ui-runtime.ts @@ -0,0 +1,21 @@ +import type { UiRuntimeOptions } from "../ui/runtime.js"; + +export function applyRuntimeUiOptions( + pluginConfig: TConfig, + deps: { + setUiRuntimeOptions: ( + options: Partial>, + ) => UiRuntimeOptions; + getCodexTuiV2: (config: TConfig) => boolean; + getCodexTuiColorProfile: ( + config: TConfig, + ) => UiRuntimeOptions["colorProfile"]; + getCodexTuiGlyphMode: (config: TConfig) => UiRuntimeOptions["glyphMode"]; + }, +): UiRuntimeOptions { + return deps.setUiRuntimeOptions({ + v2Enabled: deps.getCodexTuiV2(pluginConfig), + colorProfile: deps.getCodexTuiColorProfile(pluginConfig), + glyphMode: deps.getCodexTuiGlyphMode(pluginConfig), + }); +} From ab5a47f022509ad4432c51e2ea8e40639cd41350 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 15:09:33 +0800 Subject: [PATCH 091/376] refactor: extract account select event handler --- index.ts | 72 +++++++--------------------- lib/runtime/account-select-event.ts | 73 +++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 56 deletions(-) create mode 100644 lib/runtime/account-select-event.ts diff --git a/index.ts b/index.ts index 3f636c61..738fa590 100644 --- a/index.ts +++ b/index.ts @@ -162,6 +162,7 @@ import { withStreamingFailover } from "./lib/request/stream-failover.js"; import { addJitter } from "./lib/rotation.js"; import { persistAccountPool } from "./lib/runtime/account-pool.js"; import { applyAccountStorageScope } from "./lib/runtime/account-scope.js"; +import { handleAccountSelectEvent } from "./lib/runtime/account-select-event.js"; import { resolveAccountSelection, type TokenSuccessWithAccount, @@ -538,63 +539,22 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { event: { type: string; properties?: unknown }; }) => { try { - const { event } = input; - // Handle TUI account selection events - // Accepts generic selection events with an index property - if ( - event.type === "account.select" || - event.type === "openai.account.select" - ) { - const props = event.properties as { - index?: number; - accountIndex?: number; - provider?: string; - }; - // Filter by provider if specified - if ( - props.provider && - props.provider !== "openai" && - props.provider !== PROVIDER_ID - ) { - return; - } - - const index = props.index ?? props.accountIndex; - if (typeof index === "number") { - const storage = await loadAccounts(); - if (!storage || index < 0 || index >= storage.accounts.length) { - return; - } - - const now = Date.now(); - const account = storage.accounts[index]; - if (account) { - account.lastUsed = now; - account.lastSwitchReason = "rotation"; - } - storage.activeIndex = index; - storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; - for (const family of MODEL_FAMILIES) { - storage.activeIndexByFamily[family] = index; - } - - await saveAccounts(storage); - if (cachedAccountManager) { - await cachedAccountManager.syncCodexCliActiveSelectionForIndex( - index, - ); - } + const handled = await handleAccountSelectEvent({ + event: input.event, + providerId: PROVIDER_ID, + loadAccounts, + saveAccounts, + modelFamilies: MODEL_FAMILIES, + cachedAccountManager, + reloadAccountManagerFromDisk: async () => { + await reloadAccountManagerFromDisk(); + }, + setLastCodexCliActiveSyncIndex: (index) => { lastCodexCliActiveSyncIndex = index; - - // Reload manager from disk so we don't overwrite newer rotated - // refresh tokens with stale in-memory state. - if (cachedAccountManager) { - await reloadAccountManagerFromDisk(); - } - - await showToast(`Switched to account ${index + 1}`, "info"); - } - } + }, + showToast, + }); + if (handled) return; } catch (error) { logDebug( `[${PLUGIN_NAME}] Event handler error: ${error instanceof Error ? error.message : String(error)}`, diff --git a/lib/runtime/account-select-event.ts b/lib/runtime/account-select-event.ts new file mode 100644 index 00000000..acdac97b --- /dev/null +++ b/lib/runtime/account-select-event.ts @@ -0,0 +1,73 @@ +import type { ModelFamily } from "../prompts/codex.js"; +import type { AccountStorageV3 } from "../storage.js"; + +export async function handleAccountSelectEvent(input: { + event: { type: string; properties?: unknown }; + providerId: string; + loadAccounts: () => Promise; + saveAccounts: (storage: AccountStorageV3) => Promise; + modelFamilies: readonly ModelFamily[]; + cachedAccountManager: { + syncCodexCliActiveSelectionForIndex(index: number): Promise; + } | null; + reloadAccountManagerFromDisk: () => Promise; + setLastCodexCliActiveSyncIndex: (index: number) => void; + showToast: ( + message: string, + variant?: "info" | "success" | "warning" | "error", + ) => Promise; +}): Promise { + const { event } = input; + if ( + event.type !== "account.select" && + event.type !== "openai.account.select" + ) { + return false; + } + + const props = event.properties as { + index?: number; + accountIndex?: number; + provider?: string; + }; + if ( + props.provider && + props.provider !== "openai" && + props.provider !== input.providerId + ) { + return true; + } + + const index = props.index ?? props.accountIndex; + if (typeof index !== "number") return true; + + const storage = await input.loadAccounts(); + if (!storage || index < 0 || index >= storage.accounts.length) { + return true; + } + + const now = Date.now(); + const account = storage.accounts[index]; + if (account) { + account.lastUsed = now; + account.lastSwitchReason = "rotation"; + } + storage.activeIndex = index; + storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; + for (const family of input.modelFamilies) { + storage.activeIndexByFamily[family] = index; + } + + await input.saveAccounts(storage); + if (input.cachedAccountManager) { + await input.cachedAccountManager.syncCodexCliActiveSelectionForIndex(index); + } + input.setLastCodexCliActiveSyncIndex(index); + + if (input.cachedAccountManager) { + await input.reloadAccountManagerFromDisk(); + } + + await input.showToast(`Switched to account ${index + 1}`, "info"); + return true; +} From 8f18512175f331dcb3f8b591bb4e6b4aa33e4f59 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 15:24:09 +0800 Subject: [PATCH 092/376] fix: reuse storage error factory in save path --- lib/storage.ts | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index 873a332f..a529807f 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -13,7 +13,7 @@ import { import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; import { AnyAccountStorageSchema, getValidationErrors } from "./schemas.js"; -import { formatStorageErrorHint } from "./storage/error-hints.js"; +import { toStorageError } from "./storage/error-hints.js"; export { StorageError } from "./errors.js"; export { formatStorageErrorHint } from "./storage/error-hints.js"; @@ -2004,23 +2004,20 @@ async function saveAccountsUnlocked(storage: AccountStorageV3): Promise { } const err = error as NodeJS.ErrnoException; - const code = err?.code || "UNKNOWN"; - const hint = formatStorageErrorHint(error, path); + const storageError = toStorageError( + `Failed to save accounts: ${err?.message || "Unknown error"}`, + error, + path, + ); log.error("Failed to save accounts", { path, - code, + code: storageError.code, message: err?.message, - hint, + hint: storageError.hint, }); - throw new StorageError( - `Failed to save accounts: ${err?.message || "Unknown error"}`, - code, - path, - hint, - err instanceof Error ? err : undefined, - ); + throw storageError; } } From d2421bc0c1731175a886dde3c6958b2283e0520f Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 15:24:09 +0800 Subject: [PATCH 093/376] fix: avoid stale flagged restore writes --- lib/codex-manager/repair-commands.ts | 46 ++++++++++++++++++- test/repair-commands.test.ts | 67 ++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/lib/codex-manager/repair-commands.ts b/lib/codex-manager/repair-commands.ts index d0ea3d05..019233cf 100644 --- a/lib/codex-manager/repair-commands.ts +++ b/lib/codex-manager/repair-commands.ts @@ -454,6 +454,34 @@ function findMatchingFlaggedAccountIndex( }); } +function findFlaggedAccountIndexByStableIdentity( + accounts: readonly FlaggedAccountMetadataV1[], + target: FlaggedAccountMetadataV1, +): number { + const targetEmail = sanitizeEmail(target.email); + return accounts.findIndex((account) => { + if (target.accountId && account.accountId === target.accountId) { + if (!targetEmail) { + return true; + } + return sanitizeEmail(account.email) === targetEmail; + } + return Boolean(targetEmail) && sanitizeEmail(account.email) === targetEmail; + }); +} + +function hasFlaggedRefreshTokenDrift( + accounts: readonly FlaggedAccountMetadataV1[], + target: FlaggedAccountMetadataV1, +): boolean { + const targetIndex = findFlaggedAccountIndexByStableIdentity(accounts, target); + if (targetIndex < 0) { + return false; + } + const current = accounts[targetIndex]; + return current ? current.refreshToken !== target.refreshToken : false; +} + function applyFlaggedStorageMutations( flaggedStorage: FlaggedAccountStorageV1, mutations: readonly FlaggedStorageMutation[], @@ -983,7 +1011,23 @@ export async function runVerifyFlagged( ? structuredClone(loadedStorage) : createEmptyAccountStorage(); const nextFlaggedStorage = structuredClone(loadedFlaggedStorage); - applyRefreshChecks(nextStorage, refreshChecks); + const staleRefreshChecks = refreshChecks.filter((check) => + hasFlaggedRefreshTokenDrift(nextFlaggedStorage.accounts, check.flagged), + ); + const safeRefreshChecks = refreshChecks.filter( + (check) => + !hasFlaggedRefreshTokenDrift(nextFlaggedStorage.accounts, check.flagged), + ); + applyRefreshChecks(nextStorage, safeRefreshChecks); + for (const check of staleRefreshChecks) { + reports.push({ + index: check.index, + label: check.label, + outcome: "restore-skipped", + message: + "Skipped restore because flagged refresh token changed before persistence", + }); + } applyFlaggedStorageMutations(nextFlaggedStorage, flaggedMutations); remainingFlagged = nextFlaggedStorage.accounts.length; if (!storageChanged && !flaggedChanged) { diff --git a/test/repair-commands.test.ts b/test/repair-commands.test.ts index 513e0ce5..fddc949d 100644 --- a/test/repair-commands.test.ts +++ b/test/repair-commands.test.ts @@ -273,6 +273,73 @@ describe("repair-commands direct deps coverage", () => { }); }); + it("runVerifyFlagged skips stale restore results when flagged refresh tokens changed before persistence", async () => { + const flaggedAccount = { + email: "flagged@example.com", + refreshToken: "flagged-refresh", + accessToken: "old-access", + expiresAt: 10, + accountId: "stored-account", + accountIdSource: "manual" as const, + lastError: "old-error", + lastUsed: 1, + }; + const persistSpy = vi.fn(); + + loadFlaggedAccountsMock.mockResolvedValue({ + version: 1, + accounts: [structuredClone(flaggedAccount)], + }); + queuedRefreshMock.mockResolvedValue({ + type: "success", + access: "fresh-access", + refresh: "fresh-refresh", + expires: 999, + idToken: "fresh-id-token", + }); + extractAccountEmailMock.mockReturnValue("flagged@example.com"); + extractAccountIdMock.mockReturnValue("token-account"); + withAccountAndFlaggedStorageTransactionMock.mockImplementation(async (handler) => + handler( + null, + persistSpy, + { + version: 1, + accounts: [ + { + ...structuredClone(flaggedAccount), + refreshToken: "rotated-refresh", + }, + ], + }, + ), + ); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const exitCode = await runVerifyFlagged( + ["--json"], + createDeps(), + ); + + expect(exitCode).toBe(0); + expect(withAccountAndFlaggedStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(persistSpy).not.toHaveBeenCalled(); + expect( + JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")), + ).toMatchObject({ + total: 1, + restored: 0, + remainingFlagged: 1, + changed: false, + reports: [ + expect.objectContaining({ + outcome: "restore-skipped", + message: expect.stringContaining("changed before persistence"), + }), + ], + }); + }); + it("runFix uses the injected token-identity applier in the direct concurrent-write path", async () => { const prescanStorage = { version: 3, From fa02a1e569695eaaecbf259f527a77cb44d988c3 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 15:31:27 +0800 Subject: [PATCH 094/376] refactor: extract runtime status marker helper --- index.ts | 12 ++---------- lib/runtime/status-marker.ts | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 10 deletions(-) create mode 100644 lib/runtime/status-marker.ts diff --git a/index.ts b/index.ts index 738fa590..1ad4d50b 100644 --- a/index.ts +++ b/index.ts @@ -186,6 +186,7 @@ import { runOAuthBrowserFlow } from "./lib/runtime/oauth-browser-flow.js"; import { applyRuntimePreemptiveQuotaSettings } from "./lib/runtime/preemptive-quota.js"; import { ensureRuntimeRefreshGuardian } from "./lib/runtime/refresh-guardian.js"; import { ensureRuntimeSessionAffinity } from "./lib/runtime/session-affinity.js"; +import { getRuntimeStatusMarker } from "./lib/runtime/status-marker.js"; import { showRuntimeToast } from "./lib/runtime/toast.js"; import { applyRuntimeUiOptions } from "./lib/runtime/ui-runtime.js"; import { SessionAffinityStore } from "./lib/session-affinity.js"; @@ -409,16 +410,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const getStatusMarker = ( ui: UiRuntimeOptions, status: "ok" | "warning" | "error", - ): string => { - if (!ui.v2Enabled) { - if (status === "ok") return "✓"; - if (status === "warning") return "!"; - return "✗"; - } - if (status === "ok") return ui.theme.glyphs.check; - if (status === "warning") return "!"; - return ui.theme.glyphs.cross; - }; + ): string => getRuntimeStatusMarker(ui, status); const invalidateAccountManagerCache = (): void => { cachedAccountManager = null; diff --git a/lib/runtime/status-marker.ts b/lib/runtime/status-marker.ts new file mode 100644 index 00000000..0f729819 --- /dev/null +++ b/lib/runtime/status-marker.ts @@ -0,0 +1,15 @@ +import type { UiRuntimeOptions } from "../ui/runtime.js"; + +export function getRuntimeStatusMarker( + ui: UiRuntimeOptions, + status: "ok" | "warning" | "error", +): string { + if (!ui.v2Enabled) { + if (status === "ok") return "✓"; + if (status === "warning") return "!"; + return "✗"; + } + if (status === "ok") return ui.theme.glyphs.check; + if (status === "warning") return "!"; + return ui.theme.glyphs.cross; +} From 556b9120ec52dd6861c932b05d457dc10978866b Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 15:40:31 +0800 Subject: [PATCH 095/376] refactor: extract runtime account manager cache --- index.ts | 45 +++++++++++++++++----------- lib/runtime/account-manager-cache.ts | 36 ++++++++++++++++++++++ 2 files changed, 63 insertions(+), 18 deletions(-) create mode 100644 lib/runtime/account-manager-cache.ts diff --git a/index.ts b/index.ts index 1ad4d50b..8f0abde0 100644 --- a/index.ts +++ b/index.ts @@ -160,6 +160,10 @@ import { applyFastSessionDefaults } from "./lib/request/request-transformer.js"; import { isEmptyResponse } from "./lib/request/response-handler.js"; import { withStreamingFailover } from "./lib/request/stream-failover.js"; import { addJitter } from "./lib/rotation.js"; +import { + invalidateRuntimeAccountManagerCache, + reloadRuntimeAccountManager, +} from "./lib/runtime/account-manager-cache.js"; import { persistAccountPool } from "./lib/runtime/account-pool.js"; import { applyAccountStorageScope } from "./lib/runtime/account-scope.js"; import { handleAccountSelectEvent } from "./lib/runtime/account-select-event.js"; @@ -413,28 +417,33 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ): string => getRuntimeStatusMarker(ui, status); const invalidateAccountManagerCache = (): void => { - cachedAccountManager = null; - accountManagerPromise = null; + invalidateRuntimeAccountManagerCache({ + setCachedAccountManager: (value) => { + cachedAccountManager = value as AccountManager | null; + }, + setAccountManagerPromise: (value) => { + accountManagerPromise = value as Promise | null; + }, + }); }; const reloadAccountManagerFromDisk = async ( authFallback?: OAuthAuthDetails, - ): Promise => { - if (accountReloadInFlight) { - return accountReloadInFlight; - } - accountReloadInFlight = (async () => { - const reloaded = await AccountManager.loadFromDisk(authFallback); - cachedAccountManager = reloaded; - accountManagerPromise = Promise.resolve(reloaded); - return reloaded; - })(); - try { - return await accountReloadInFlight; - } finally { - accountReloadInFlight = null; - } - }; + ): Promise => + reloadRuntimeAccountManager({ + currentReloadInFlight: accountReloadInFlight, + loadFromDisk: (fallback) => AccountManager.loadFromDisk(fallback), + setCachedAccountManager: (value) => { + cachedAccountManager = value; + }, + setAccountManagerPromise: (value) => { + accountManagerPromise = value; + }, + setReloadInFlight: (value) => { + accountReloadInFlight = value; + }, + authFallback, + }); const applyStorageScope = ( pluginConfig: ReturnType, diff --git a/lib/runtime/account-manager-cache.ts b/lib/runtime/account-manager-cache.ts new file mode 100644 index 00000000..8094e739 --- /dev/null +++ b/lib/runtime/account-manager-cache.ts @@ -0,0 +1,36 @@ +import type { OAuthAuthDetails } from "../types.js"; + +export function invalidateRuntimeAccountManagerCache(deps: { + setCachedAccountManager: (value: unknown) => void; + setAccountManagerPromise: (value: Promise | null) => void; +}): void { + deps.setCachedAccountManager(null); + deps.setAccountManagerPromise(null); +} + +export async function reloadRuntimeAccountManager(deps: { + currentReloadInFlight: Promise | null; + loadFromDisk: (authFallback?: OAuthAuthDetails) => Promise; + setCachedAccountManager: (value: TAccountManager) => void; + setAccountManagerPromise: (value: Promise | null) => void; + setReloadInFlight: (value: Promise | null) => void; + authFallback?: OAuthAuthDetails; +}): Promise { + if (deps.currentReloadInFlight) { + return deps.currentReloadInFlight; + } + + const reloadInFlight = (async () => { + const reloaded = await deps.loadFromDisk(deps.authFallback); + deps.setCachedAccountManager(reloaded); + deps.setAccountManagerPromise(Promise.resolve(reloaded)); + return reloaded; + })(); + + deps.setReloadInFlight(reloadInFlight); + try { + return await reloadInFlight; + } finally { + deps.setReloadInFlight(null); + } +} From c610acc59de8125c27afa0df742bb3bf01d951e9 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 15:42:48 +0800 Subject: [PATCH 096/376] refactor: extract runtime ui resolver --- index.ts | 10 ++++++++-- lib/runtime/ui-runtime.ts | 7 +++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/index.ts b/index.ts index 8f0abde0..0a45563f 100644 --- a/index.ts +++ b/index.ts @@ -192,7 +192,10 @@ import { ensureRuntimeRefreshGuardian } from "./lib/runtime/refresh-guardian.js" import { ensureRuntimeSessionAffinity } from "./lib/runtime/session-affinity.js"; import { getRuntimeStatusMarker } from "./lib/runtime/status-marker.js"; import { showRuntimeToast } from "./lib/runtime/toast.js"; -import { applyRuntimeUiOptions } from "./lib/runtime/ui-runtime.js"; +import { + applyRuntimeUiOptions, + resolveRuntimeUiOptions, +} from "./lib/runtime/ui-runtime.js"; import { SessionAffinityStore } from "./lib/session-affinity.js"; import { registerCleanup } from "./lib/shutdown.js"; import { @@ -408,7 +411,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }; const resolveUiRuntime = (): UiRuntimeOptions => { - return applyUiRuntimeFromConfig(loadPluginConfig()); + return resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); }; const getStatusMarker = ( diff --git a/lib/runtime/ui-runtime.ts b/lib/runtime/ui-runtime.ts index 0bf07fbe..e78d9654 100644 --- a/lib/runtime/ui-runtime.ts +++ b/lib/runtime/ui-runtime.ts @@ -19,3 +19,10 @@ export function applyRuntimeUiOptions( glyphMode: deps.getCodexTuiGlyphMode(pluginConfig), }); } + +export function resolveRuntimeUiOptions(deps: { + loadPluginConfig: () => TConfig; + applyUiRuntimeFromConfig: (config: TConfig) => UiRuntimeOptions; +}): UiRuntimeOptions { + return deps.applyUiRuntimeFromConfig(deps.loadPluginConfig()); +} From 73b79cca23cda3fdef8884a1b9e83ee7f011eafd Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 15:48:11 +0800 Subject: [PATCH 097/376] refactor: extract runtime request init helpers --- index.ts | 82 ++++--------------------------------- lib/runtime/request-init.ts | 72 ++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 75 deletions(-) create mode 100644 lib/runtime/request-init.ts diff --git a/index.ts b/index.ts index 0a45563f..890989ef 100644 --- a/index.ts +++ b/index.ts @@ -189,6 +189,10 @@ import { import { runOAuthBrowserFlow } from "./lib/runtime/oauth-browser-flow.js"; import { applyRuntimePreemptiveQuotaSettings } from "./lib/runtime/preemptive-quota.js"; import { ensureRuntimeRefreshGuardian } from "./lib/runtime/refresh-guardian.js"; +import { + normalizeRuntimeRequestInit, + parseRuntimeRequestBody, +} from "./lib/runtime/request-init.js"; import { ensureRuntimeSessionAffinity } from "./lib/runtime/session-affinity.js"; import { getRuntimeStatusMarker } from "./lib/runtime/status-marker.js"; import { showRuntimeToast } from "./lib/runtime/toast.js"; @@ -801,82 +805,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { // Instructions are fetched per model family (codex-max, codex, gpt-5.1) // Capture original stream value before transformation // generateText() sends no stream field, streamText() sends stream=true - const normalizeRequestInit = async ( - requestInput: Request | string | URL, - requestInit: RequestInit | undefined, - ): Promise => { - if (requestInit) return requestInit; - if (!(requestInput instanceof Request)) return requestInit; - - const method = requestInput.method || "GET"; - const normalized: RequestInit = { - method, - headers: new Headers(requestInput.headers), - }; - - if (method !== "GET" && method !== "HEAD") { - try { - const bodyText = await requestInput.clone().text(); - if (bodyText) { - normalized.body = bodyText; - } - } catch { - // Body may be unreadable; proceed without it. - } - } - - return normalized; - }; - - const parseRequestBodyFromInit = async ( - body: unknown, - ): Promise> => { - if (!body) return {}; - - try { - if (typeof body === "string") { - return JSON.parse(body) as Record; - } - - if (body instanceof Uint8Array) { - return JSON.parse( - new TextDecoder().decode(body), - ) as Record; - } - - if (body instanceof ArrayBuffer) { - return JSON.parse( - new TextDecoder().decode(new Uint8Array(body)), - ) as Record; - } - - if (ArrayBuffer.isView(body)) { - const view = new Uint8Array( - body.buffer, - body.byteOffset, - body.byteLength, - ); - return JSON.parse( - new TextDecoder().decode(view), - ) as Record; - } - - if (typeof Blob !== "undefined" && body instanceof Blob) { - return JSON.parse(await body.text()) as Record< - string, - unknown - >; - } - } catch { - logWarn("Failed to parse request body, using empty object"); - } - - return {}; - }; - - const baseInit = await normalizeRequestInit(input, init); - const originalBody = await parseRequestBodyFromInit( + const baseInit = await normalizeRuntimeRequestInit(input, init); + const originalBody = await parseRuntimeRequestBody( baseInit?.body, + { logWarn }, ); const isStreaming = originalBody.stream === true; const parsedBody = diff --git a/lib/runtime/request-init.ts b/lib/runtime/request-init.ts new file mode 100644 index 00000000..e6198c50 --- /dev/null +++ b/lib/runtime/request-init.ts @@ -0,0 +1,72 @@ +export async function normalizeRuntimeRequestInit( + requestInput: Request | string | URL, + requestInit: RequestInit | undefined, +): Promise { + if (requestInit) return requestInit; + if (!(requestInput instanceof Request)) return requestInit; + + const method = requestInput.method || "GET"; + const normalized: RequestInit = { + method, + headers: new Headers(requestInput.headers), + }; + + if (method !== "GET" && method !== "HEAD") { + try { + const bodyText = await requestInput.clone().text(); + if (bodyText) { + normalized.body = bodyText; + } + } catch { + // Body may be unreadable; proceed without it. + } + } + + return normalized; +} + +export async function parseRuntimeRequestBody( + body: unknown, + deps: { logWarn: (message: string) => void }, +): Promise> { + if (!body) return {}; + + try { + if (typeof body === "string") { + return JSON.parse(body) as Record; + } + + if (body instanceof Uint8Array) { + return JSON.parse(new TextDecoder().decode(body)) as Record< + string, + unknown + >; + } + + if (body instanceof ArrayBuffer) { + return JSON.parse( + new TextDecoder().decode(new Uint8Array(body)), + ) as Record; + } + + if (ArrayBuffer.isView(body)) { + const view = new Uint8Array( + body.buffer, + body.byteOffset, + body.byteLength, + ); + return JSON.parse(new TextDecoder().decode(view)) as Record< + string, + unknown + >; + } + + if (typeof Blob !== "undefined" && body instanceof Blob) { + return JSON.parse(await body.text()) as Record; + } + } catch { + deps.logWarn("Failed to parse request body, using empty object"); + } + + return {}; +} From da6b3f83c58a1105629f06703f6b0472b8775729 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 15:52:58 +0800 Subject: [PATCH 098/376] refactor: extract runtime capability boost map --- index.ts | 45 +++++++++---------------- lib/runtime/capability-boost.ts | 58 +++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 29 deletions(-) create mode 100644 lib/runtime/capability-boost.ts diff --git a/index.ts b/index.ts index 890989ef..b3437bd3 100644 --- a/index.ts +++ b/index.ts @@ -176,6 +176,7 @@ import { getRateLimitResetTimeForFamily, resolveActiveIndex, } from "./lib/runtime/account-state.js"; +import { buildCapabilityBoostByAccount } from "./lib/runtime/capability-boost.js"; import { ensureRuntimeLiveAccountSync } from "./lib/runtime/live-sync.js"; import { buildManualOAuthFlow } from "./lib/runtime/manual-oauth-flow.js"; import { @@ -942,38 +943,24 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { index: number, ) => AccountSnapshotCandidate | null; }; - const accountSnapshotList = - typeof accountSnapshotSource.getAccountsSnapshot === - "function" - ? (accountSnapshotSource.getAccountsSnapshot() ?? []) - : []; - if ( - accountSnapshotList.length === 0 && - typeof accountSnapshotSource.getAccountByIndex === - "function" + const capabilityBoostList = buildCapabilityBoostByAccount({ + accountCount, + model, + modelFamily, + accountSnapshotSource, + getBoost: (accountKey, capabilityKey) => + capabilityPolicyStore.getBoost(accountKey, capabilityKey), + }); + for ( + let boostIndex = 0; + boostIndex < capabilityBoostList.length; + boostIndex += 1 ) { - for ( - let accountSnapshotIndex = 0; - accountSnapshotIndex < accountCount; - accountSnapshotIndex += 1 - ) { - const candidate = - accountSnapshotSource.getAccountByIndex( - accountSnapshotIndex, - ); - if (candidate) { - accountSnapshotList.push(candidate); - } + const boost = capabilityBoostList[boostIndex]; + if (typeof boost === "number") { + capabilityBoostByAccount[boostIndex] = boost; } } - for (const candidate of accountSnapshotList) { - const accountKey = resolveEntitlementAccountKey(candidate); - capabilityBoostByAccount[candidate.index] = - capabilityPolicyStore.getBoost( - accountKey, - model ?? modelFamily, - ); - } accountAttemptLoop: while ( attempted.size < Math.max(1, accountCount) diff --git a/lib/runtime/capability-boost.ts b/lib/runtime/capability-boost.ts new file mode 100644 index 00000000..0e5817ba --- /dev/null +++ b/lib/runtime/capability-boost.ts @@ -0,0 +1,58 @@ +import { resolveEntitlementAccountKey } from "../entitlement-cache.js"; + +export type AccountSnapshotCandidate = { + index: number; + email?: string; + accountId?: string; + accountLabel?: string; + currentWorkspaceIndex?: number; + workspaces?: Array<{ + id: string; + name?: string; + enabled?: boolean; + isDefault?: boolean; + disabledAt?: number; + }>; +}; + +export function buildCapabilityBoostByAccount(input: { + accountCount: number; + model?: string; + modelFamily: string; + accountSnapshotSource: { + getAccountsSnapshot?: () => AccountSnapshotCandidate[]; + getAccountByIndex?: (index: number) => AccountSnapshotCandidate | null; + }; + getBoost: (accountKey: string, capabilityKey: string) => number; +}): number[] { + const boosts = new Array(Math.max(0, input.accountCount)).fill(0); + const accountSnapshotList = + typeof input.accountSnapshotSource.getAccountsSnapshot === "function" + ? (input.accountSnapshotSource.getAccountsSnapshot() ?? []) + : []; + + if ( + accountSnapshotList.length === 0 && + typeof input.accountSnapshotSource.getAccountByIndex === "function" + ) { + for ( + let accountSnapshotIndex = 0; + accountSnapshotIndex < input.accountCount; + accountSnapshotIndex += 1 + ) { + const candidate = + input.accountSnapshotSource.getAccountByIndex(accountSnapshotIndex); + if (candidate) accountSnapshotList.push(candidate); + } + } + + for (const candidate of accountSnapshotList) { + const accountKey = resolveEntitlementAccountKey(candidate); + boosts[candidate.index] = input.getBoost( + accountKey, + input.model ?? input.modelFamily, + ); + } + + return boosts; +} From 3bcbf8f86f2a7ce03284051d12bd0d8ca6605d93 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 16:00:32 +0800 Subject: [PATCH 099/376] refactor: extract runtime event handler --- index.ts | 44 ++++++++++++----------------- lib/runtime/event-handler.ts | 55 ++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 26 deletions(-) create mode 100644 lib/runtime/event-handler.ts diff --git a/index.ts b/index.ts index b3437bd3..54a81a4d 100644 --- a/index.ts +++ b/index.ts @@ -177,6 +177,7 @@ import { resolveActiveIndex, } from "./lib/runtime/account-state.js"; import { buildCapabilityBoostByAccount } from "./lib/runtime/capability-boost.js"; +import { createRuntimeEventHandler } from "./lib/runtime/event-handler.js"; import { ensureRuntimeLiveAccountSync } from "./lib/runtime/live-sync.js"; import { buildManualOAuthFlow } from "./lib/runtime/manual-oauth-flow.js"; import { @@ -547,32 +548,23 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }; // Event handler for session recovery and account selection - const eventHandler = async (input: { - event: { type: string; properties?: unknown }; - }) => { - try { - const handled = await handleAccountSelectEvent({ - event: input.event, - providerId: PROVIDER_ID, - loadAccounts, - saveAccounts, - modelFamilies: MODEL_FAMILIES, - cachedAccountManager, - reloadAccountManagerFromDisk: async () => { - await reloadAccountManagerFromDisk(); - }, - setLastCodexCliActiveSyncIndex: (index) => { - lastCodexCliActiveSyncIndex = index; - }, - showToast, - }); - if (handled) return; - } catch (error) { - logDebug( - `[${PLUGIN_NAME}] Event handler error: ${error instanceof Error ? error.message : String(error)}`, - ); - } - }; + const eventHandler = createRuntimeEventHandler({ + handleAccountSelectEvent, + providerId: PROVIDER_ID, + loadAccounts, + saveAccounts, + modelFamilies: MODEL_FAMILIES, + getCachedAccountManager: () => cachedAccountManager, + reloadAccountManagerFromDisk: async () => { + await reloadAccountManagerFromDisk(); + }, + setLastCodexCliActiveSyncIndex: (index) => { + lastCodexCliActiveSyncIndex = index; + }, + showToast, + logDebug, + pluginName: PLUGIN_NAME, + }); // Initialize runtime UI settings once on plugin load; auth/tools refresh this dynamically. resolveUiRuntime(); diff --git a/lib/runtime/event-handler.ts b/lib/runtime/event-handler.ts new file mode 100644 index 00000000..f0839016 --- /dev/null +++ b/lib/runtime/event-handler.ts @@ -0,0 +1,55 @@ +export function createRuntimeEventHandler< + TLoadedStorage, + TSavedStorage, + TModelFamily extends string, + TManager, +>(deps: { + handleAccountSelectEvent: (input: { + event: { type: string; properties?: unknown }; + providerId: string; + loadAccounts: () => Promise; + saveAccounts: (storage: TSavedStorage) => Promise; + modelFamilies: readonly TModelFamily[]; + cachedAccountManager: TManager; + reloadAccountManagerFromDisk: () => Promise; + setLastCodexCliActiveSyncIndex: (index: number) => void; + showToast: ( + message: string, + variant?: "info" | "success" | "warning" | "error", + ) => Promise; + }) => Promise; + providerId: string; + loadAccounts: () => Promise; + saveAccounts: (storage: TSavedStorage) => Promise; + modelFamilies: readonly TModelFamily[]; + getCachedAccountManager: () => TManager; + reloadAccountManagerFromDisk: () => Promise; + setLastCodexCliActiveSyncIndex: (index: number) => void; + showToast: ( + message: string, + variant?: "info" | "success" | "warning" | "error", + ) => Promise; + logDebug: (message: string) => void; + pluginName: string; +}) { + return async (input: { event: { type: string; properties?: unknown } }) => { + try { + const handled = await deps.handleAccountSelectEvent({ + event: input.event, + providerId: deps.providerId, + loadAccounts: deps.loadAccounts, + saveAccounts: deps.saveAccounts, + modelFamilies: deps.modelFamilies, + cachedAccountManager: deps.getCachedAccountManager(), + reloadAccountManagerFromDisk: deps.reloadAccountManagerFromDisk, + setLastCodexCliActiveSyncIndex: deps.setLastCodexCliActiveSyncIndex, + showToast: deps.showToast, + }); + if (handled) return; + } catch (error) { + deps.logDebug( + `[${deps.pluginName}] Event handler error: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }; +} From ee1bf9590a4a1d66796f7a4541e365f849c9aa69 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 16:07:07 +0800 Subject: [PATCH 100/376] refactor: extract runtime quota header helpers --- index.ts | 214 +---------------------------------- lib/runtime/quota-headers.ts | 189 +++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+), 209 deletions(-) create mode 100644 lib/runtime/quota-headers.ts diff --git a/index.ts b/index.ts index 54a81a4d..519e3c5b 100644 --- a/index.ts +++ b/index.ts @@ -190,6 +190,11 @@ import { } from "./lib/runtime/metrics.js"; import { runOAuthBrowserFlow } from "./lib/runtime/oauth-browser-flow.js"; import { applyRuntimePreemptiveQuotaSettings } from "./lib/runtime/preemptive-quota.js"; +import { + type CodexQuotaSnapshot, + formatCodexQuotaLine, + parseCodexQuotaSnapshot, +} from "./lib/runtime/quota-headers.js"; import { ensureRuntimeRefreshGuardian } from "./lib/runtime/refresh-guardian.js"; import { normalizeRuntimeRequestInit, @@ -2424,215 +2429,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ); }; - type CodexQuotaWindow = { - usedPercent?: number; - windowMinutes?: number; - resetAtMs?: number; - }; - - type CodexQuotaSnapshot = { - status: number; - planType?: string; - activeLimit?: number; - primary: CodexQuotaWindow; - secondary: CodexQuotaWindow; - }; - - const parseFiniteNumberHeader = ( - headers: Headers, - name: string, - ): number | undefined => { - const raw = headers.get(name); - if (!raw) return undefined; - const parsed = Number(raw); - return Number.isFinite(parsed) ? parsed : undefined; - }; - - const parseFiniteIntHeader = ( - headers: Headers, - name: string, - ): number | undefined => { - const raw = headers.get(name); - if (!raw) return undefined; - const parsed = Number.parseInt(raw, 10); - return Number.isFinite(parsed) ? parsed : undefined; - }; - - const parseResetAtMs = ( - headers: Headers, - prefix: string, - ): number | undefined => { - const resetAfterSeconds = parseFiniteIntHeader( - headers, - `${prefix}-reset-after-seconds`, - ); - if ( - typeof resetAfterSeconds === "number" && - Number.isFinite(resetAfterSeconds) && - resetAfterSeconds > 0 - ) { - return Date.now() + resetAfterSeconds * 1000; - } - - const resetAtRaw = headers.get(`${prefix}-reset-at`); - if (!resetAtRaw) return undefined; - - const trimmed = resetAtRaw.trim(); - if (/^\d+$/.test(trimmed)) { - const parsedNumber = Number.parseInt(trimmed, 10); - if (Number.isFinite(parsedNumber) && parsedNumber > 0) { - // Upstream sometimes returns seconds since epoch. - return parsedNumber < 10_000_000_000 - ? parsedNumber * 1000 - : parsedNumber; - } - } - - const parsedDate = Date.parse(trimmed); - return Number.isFinite(parsedDate) ? parsedDate : undefined; - }; - - const hasCodexQuotaHeaders = (headers: Headers): boolean => { - const keys = [ - "x-codex-primary-used-percent", - "x-codex-primary-window-minutes", - "x-codex-primary-reset-at", - "x-codex-primary-reset-after-seconds", - "x-codex-secondary-used-percent", - "x-codex-secondary-window-minutes", - "x-codex-secondary-reset-at", - "x-codex-secondary-reset-after-seconds", - ]; - return keys.some((key) => headers.get(key) !== null); - }; - - const parseCodexQuotaSnapshot = ( - headers: Headers, - status: number, - ): CodexQuotaSnapshot | null => { - if (!hasCodexQuotaHeaders(headers)) return null; - - const primaryPrefix = "x-codex-primary"; - const secondaryPrefix = "x-codex-secondary"; - const primary: CodexQuotaWindow = { - usedPercent: parseFiniteNumberHeader( - headers, - `${primaryPrefix}-used-percent`, - ), - windowMinutes: parseFiniteIntHeader( - headers, - `${primaryPrefix}-window-minutes`, - ), - resetAtMs: parseResetAtMs(headers, primaryPrefix), - }; - const secondary: CodexQuotaWindow = { - usedPercent: parseFiniteNumberHeader( - headers, - `${secondaryPrefix}-used-percent`, - ), - windowMinutes: parseFiniteIntHeader( - headers, - `${secondaryPrefix}-window-minutes`, - ), - resetAtMs: parseResetAtMs(headers, secondaryPrefix), - }; - - const planTypeRaw = headers.get("x-codex-plan-type"); - const planType = - planTypeRaw && planTypeRaw.trim() - ? planTypeRaw.trim() - : undefined; - const activeLimit = parseFiniteIntHeader( - headers, - "x-codex-active-limit", - ); - - return { status, planType, activeLimit, primary, secondary }; - }; - - const formatQuotaWindowLabel = ( - windowMinutes: number | undefined, - ): string => { - if ( - !windowMinutes || - !Number.isFinite(windowMinutes) || - windowMinutes <= 0 - ) { - return "quota"; - } - if (windowMinutes % 1440 === 0) return `${windowMinutes / 1440}d`; - if (windowMinutes % 60 === 0) return `${windowMinutes / 60}h`; - return `${windowMinutes}m`; - }; - - const formatResetAt = ( - resetAtMs: number | undefined, - ): string | undefined => { - if (!resetAtMs || !Number.isFinite(resetAtMs) || resetAtMs <= 0) - return undefined; - const date = new Date(resetAtMs); - if (!Number.isFinite(date.getTime())) return undefined; - - const now = new Date(); - const sameDay = - now.getFullYear() === date.getFullYear() && - now.getMonth() === date.getMonth() && - now.getDate() === date.getDate(); - - const time = date.toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - hour12: false, - }); - - if (sameDay) return time; - const day = date.toLocaleDateString(undefined, { - month: "short", - day: "2-digit", - }); - return `${time} on ${day}`; - }; - - const formatCodexQuotaLine = ( - snapshot: CodexQuotaSnapshot, - ): string => { - const summarizeWindow = ( - label: string, - window: CodexQuotaWindow, - ): string => { - const used = window.usedPercent; - const left = - typeof used === "number" && Number.isFinite(used) - ? Math.max(0, Math.min(100, Math.round(100 - used))) - : undefined; - const reset = formatResetAt(window.resetAtMs); - let summary = label; - if (left !== undefined) summary = `${summary} ${left}% left`; - if (reset) summary = `${summary} (resets ${reset})`; - return summary; - }; - - const primaryLabel = formatQuotaWindowLabel( - snapshot.primary.windowMinutes, - ); - const secondaryLabel = formatQuotaWindowLabel( - snapshot.secondary.windowMinutes, - ); - const parts = [ - summarizeWindow(primaryLabel, snapshot.primary), - summarizeWindow(secondaryLabel, snapshot.secondary), - ]; - if (snapshot.planType) parts.push(`plan:${snapshot.planType}`); - if ( - typeof snapshot.activeLimit === "number" && - Number.isFinite(snapshot.activeLimit) - ) { - parts.push(`active:${snapshot.activeLimit}`); - } - if (snapshot.status === 429) parts.push("rate-limited"); - return parts.join(", "); - }; - const fetchCodexQuotaSnapshot = async (params: { accountId: string; accessToken: string; diff --git a/lib/runtime/quota-headers.ts b/lib/runtime/quota-headers.ts new file mode 100644 index 00000000..fb5d7a95 --- /dev/null +++ b/lib/runtime/quota-headers.ts @@ -0,0 +1,189 @@ +export type CodexQuotaWindow = { + usedPercent?: number; + windowMinutes?: number; + resetAtMs?: number; +}; + +export type CodexQuotaSnapshot = { + status: number; + planType?: string; + activeLimit?: number; + primary: CodexQuotaWindow; + secondary: CodexQuotaWindow; +}; + +export function parseFiniteNumberHeader( + headers: Headers, + name: string, +): number | undefined { + const raw = headers.get(name); + if (!raw) return undefined; + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : undefined; +} + +export function parseFiniteIntHeader( + headers: Headers, + name: string, +): number | undefined { + const raw = headers.get(name); + if (!raw) return undefined; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) ? parsed : undefined; +} + +export function parseResetAtMs( + headers: Headers, + prefix: string, +): number | undefined { + const resetAfterSeconds = parseFiniteIntHeader( + headers, + `${prefix}-reset-after-seconds`, + ); + if ( + typeof resetAfterSeconds === "number" && + Number.isFinite(resetAfterSeconds) && + resetAfterSeconds > 0 + ) { + return Date.now() + resetAfterSeconds * 1000; + } + + const resetAtRaw = headers.get(`${prefix}-reset-at`); + if (!resetAtRaw) return undefined; + + const trimmed = resetAtRaw.trim(); + if (/^\d+$/.test(trimmed)) { + const parsedNumber = Number.parseInt(trimmed, 10); + if (Number.isFinite(parsedNumber) && parsedNumber > 0) { + return parsedNumber < 10_000_000_000 ? parsedNumber * 1000 : parsedNumber; + } + } + + const parsedDate = Date.parse(trimmed); + return Number.isFinite(parsedDate) ? parsedDate : undefined; +} + +export function hasCodexQuotaHeaders(headers: Headers): boolean { + const keys = [ + "x-codex-primary-used-percent", + "x-codex-primary-window-minutes", + "x-codex-primary-reset-at", + "x-codex-primary-reset-after-seconds", + "x-codex-secondary-used-percent", + "x-codex-secondary-window-minutes", + "x-codex-secondary-reset-at", + "x-codex-secondary-reset-after-seconds", + ]; + return keys.some((key) => headers.get(key) !== null); +} + +export function parseCodexQuotaSnapshot( + headers: Headers, + status: number, +): CodexQuotaSnapshot | null { + if (!hasCodexQuotaHeaders(headers)) return null; + + const primaryPrefix = "x-codex-primary"; + const secondaryPrefix = "x-codex-secondary"; + const primary: CodexQuotaWindow = { + usedPercent: parseFiniteNumberHeader( + headers, + `${primaryPrefix}-used-percent`, + ), + windowMinutes: parseFiniteIntHeader( + headers, + `${primaryPrefix}-window-minutes`, + ), + resetAtMs: parseResetAtMs(headers, primaryPrefix), + }; + const secondary: CodexQuotaWindow = { + usedPercent: parseFiniteNumberHeader( + headers, + `${secondaryPrefix}-used-percent`, + ), + windowMinutes: parseFiniteIntHeader( + headers, + `${secondaryPrefix}-window-minutes`, + ), + resetAtMs: parseResetAtMs(headers, secondaryPrefix), + }; + + const planTypeRaw = headers.get("x-codex-plan-type"); + const planType = + planTypeRaw && planTypeRaw.trim() ? planTypeRaw.trim() : undefined; + const activeLimit = parseFiniteIntHeader(headers, "x-codex-active-limit"); + + return { status, planType, activeLimit, primary, secondary }; +} + +export function formatQuotaWindowLabel( + windowMinutes: number | undefined, +): string { + if (!windowMinutes || !Number.isFinite(windowMinutes) || windowMinutes <= 0) { + return "quota"; + } + if (windowMinutes % 1440 === 0) return `${windowMinutes / 1440}d`; + if (windowMinutes % 60 === 0) return `${windowMinutes / 60}h`; + return `${windowMinutes}m`; +} + +export function formatResetAt( + resetAtMs: number | undefined, +): string | undefined { + if (!resetAtMs || !Number.isFinite(resetAtMs) || resetAtMs <= 0) + return undefined; + const date = new Date(resetAtMs); + if (!Number.isFinite(date.getTime())) return undefined; + + const now = new Date(); + const sameDay = + now.getFullYear() === date.getFullYear() && + now.getMonth() === date.getMonth() && + now.getDate() === date.getDate(); + + const time = date.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + + if (sameDay) return time; + const day = date.toLocaleDateString(undefined, { + month: "short", + day: "2-digit", + }); + return `${time} on ${day}`; +} + +export function formatCodexQuotaLine(snapshot: CodexQuotaSnapshot): string { + const summarizeWindow = (label: string, window: CodexQuotaWindow): string => { + const used = window.usedPercent; + const left = + typeof used === "number" && Number.isFinite(used) + ? Math.max(0, Math.min(100, Math.round(100 - used))) + : undefined; + const reset = formatResetAt(window.resetAtMs); + let summary = label; + if (left !== undefined) summary = `${summary} ${left}% left`; + if (reset) summary = `${summary} (resets ${reset})`; + return summary; + }; + + const primaryLabel = formatQuotaWindowLabel(snapshot.primary.windowMinutes); + const secondaryLabel = formatQuotaWindowLabel( + snapshot.secondary.windowMinutes, + ); + const parts = [ + summarizeWindow(primaryLabel, snapshot.primary), + summarizeWindow(secondaryLabel, snapshot.secondary), + ]; + if (snapshot.planType) parts.push(`plan:${snapshot.planType}`); + if ( + typeof snapshot.activeLimit === "number" && + Number.isFinite(snapshot.activeLimit) + ) { + parts.push(`active:${snapshot.activeLimit}`); + } + if (snapshot.status === 429) parts.push("rate-limited"); + return parts.join(", "); +} From 77ea2c5d5bc12a3a841079746d1b19f227049d0c Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 16:12:36 +0800 Subject: [PATCH 101/376] refactor: extract runtime quota probe helper --- index.ts | 131 +++++-------------------------------- lib/runtime/quota-probe.ts | 121 ++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 116 deletions(-) create mode 100644 lib/runtime/quota-probe.ts diff --git a/index.ts b/index.ts index 519e3c5b..227de586 100644 --- a/index.ts +++ b/index.ts @@ -190,6 +190,7 @@ import { } from "./lib/runtime/metrics.js"; import { runOAuthBrowserFlow } from "./lib/runtime/oauth-browser-flow.js"; import { applyRuntimePreemptiveQuotaSettings } from "./lib/runtime/preemptive-quota.js"; +import { fetchRuntimeCodexQuotaSnapshot } from "./lib/runtime/quota-probe.js"; import { type CodexQuotaSnapshot, formatCodexQuotaLine, @@ -2430,123 +2431,21 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }; const fetchCodexQuotaSnapshot = async (params: { - accountId: string; - accessToken: string; - }): Promise => { - const QUOTA_PROBE_MODELS = [ - "gpt-5-codex", - "gpt-5.3-codex", - "gpt-5.2-codex", - ]; - let lastError: Error | null = null; - - for (const model of QUOTA_PROBE_MODELS) { - try { - const instructions = await getCodexInstructions(model); - const probeBody: RequestBody = { - model, - stream: true, - store: false, - include: ["reasoning.encrypted_content"], - instructions, - input: [ - { - type: "message", - role: "user", - content: [{ type: "input_text", text: "quota ping" }], - }, - ], - reasoning: { effort: "none", summary: "auto" }, - text: { verbosity: "low" }, - }; - - const headers = createCodexHeaders( - undefined, - params.accountId, - params.accessToken, - { - model, - }, - ); - headers.set( - "content-type", - "application/json; charset=utf-8", - ); - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 15_000); - let response: Response; - try { - response = await fetch( - `${CODEX_BASE_URL}/codex/responses`, - { - method: "POST", - headers, - body: JSON.stringify(probeBody), - signal: controller.signal, - }, - ); - } finally { - clearTimeout(timeout); - } - - const snapshot = parseCodexQuotaSnapshot( - response.headers, - response.status, - ); - if (snapshot) { - // We only need headers; cancel the SSE stream immediately. - try { - await response.body?.cancel(); - } catch { - // Ignore cancellation failures. - } - return snapshot; - } - - if (!response.ok) { - const bodyText = await response.text().catch(() => ""); - let errorBody: unknown; - try { - errorBody = bodyText - ? (JSON.parse(bodyText) as unknown) - : undefined; - } catch { - errorBody = { error: { message: bodyText } }; - } - - const unsupportedInfo = - getUnsupportedCodexModelInfo(errorBody); - if (unsupportedInfo.isUnsupported) { - lastError = new Error( - unsupportedInfo.message ?? - `Model '${model}' unsupported for this account`, - ); - continue; - } - - const message = - (typeof (errorBody as { error?: { message?: unknown } }) - ?.error?.message === "string" - ? (errorBody as { error?: { message?: string } }).error - ?.message - : bodyText) || `HTTP ${response.status}`; - throw new Error(message); - } - - lastError = new Error( - "Codex response did not include quota headers", - ); - } catch (error) { - lastError = - error instanceof Error ? error : new Error(String(error)); - } - } - - throw lastError ?? new Error("Failed to fetch quotas"); - }; + accountId: string; + accessToken: string; + }): Promise => + fetchRuntimeCodexQuotaSnapshot({ + accountId: params.accountId, + accessToken: params.accessToken, + baseUrl: CODEX_BASE_URL, + fetchImpl: fetch, + getCodexInstructions, + createCodexHeaders, + parseCodexQuotaSnapshot, + getUnsupportedCodexModelInfo, + }); - const runAccountCheck = async ( +const runAccountCheck = async ( deepProbe: boolean, ): Promise => { const loadedStorage = await hydrateEmails(await loadAccounts()); diff --git a/lib/runtime/quota-probe.ts b/lib/runtime/quota-probe.ts new file mode 100644 index 00000000..ce4ed274 --- /dev/null +++ b/lib/runtime/quota-probe.ts @@ -0,0 +1,121 @@ +import type { RequestBody } from "../types.js"; +import type { CodexQuotaSnapshot } from "./quota-headers.js"; + +const QUOTA_PROBE_MODELS = [ + "gpt-5-codex", + "gpt-5.3-codex", + "gpt-5.2-codex", +] as const; + +export async function fetchRuntimeCodexQuotaSnapshot(params: { + accountId: string; + accessToken: string; + baseUrl: string; + fetchImpl: typeof fetch; + getCodexInstructions: (model: string) => Promise; + createCodexHeaders: ( + init: RequestInit | undefined, + accountId: string, + accessToken: string, + meta: { model: string }, + ) => Headers; + parseCodexQuotaSnapshot: ( + headers: Headers, + status: number, + ) => CodexQuotaSnapshot | null; + getUnsupportedCodexModelInfo: (errorBody: unknown) => { + isUnsupported: boolean; + message?: string; + }; +}): Promise { + let lastError: Error | null = null; + + for (const model of QUOTA_PROBE_MODELS) { + try { + const instructions = await params.getCodexInstructions(model); + const probeBody: RequestBody = { + model, + stream: true, + store: false, + include: ["reasoning.encrypted_content"], + instructions, + input: [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "quota ping" }], + }, + ], + reasoning: { effort: "none", summary: "auto" }, + text: { verbosity: "low" }, + }; + + const headers = params.createCodexHeaders( + undefined, + params.accountId, + params.accessToken, + { model }, + ); + headers.set("content-type", "application/json; charset=utf-8"); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15_000); + let response: Response; + try { + response = await params.fetchImpl(`${params.baseUrl}/codex/responses`, { + method: "POST", + headers, + body: JSON.stringify(probeBody), + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } + + const snapshot = params.parseCodexQuotaSnapshot( + response.headers, + response.status, + ); + if (snapshot) { + try { + await response.body?.cancel(); + } catch { + // Ignore cancellation failures. + } + return snapshot; + } + + if (!response.ok) { + const bodyText = await response.text().catch(() => ""); + let errorBody: unknown; + try { + errorBody = bodyText ? (JSON.parse(bodyText) as unknown) : undefined; + } catch { + errorBody = { error: { message: bodyText } }; + } + + const unsupportedInfo = params.getUnsupportedCodexModelInfo(errorBody); + if (unsupportedInfo.isUnsupported) { + lastError = new Error( + unsupportedInfo.message ?? + `Model '${model}' unsupported for this account`, + ); + continue; + } + + const message = + (typeof (errorBody as { error?: { message?: unknown } })?.error + ?.message === "string" + ? (errorBody as { error?: { message?: string } }).error?.message + : bodyText) || `HTTP ${response.status}`; + throw new Error(message); + } + + lastError = new Error("Codex response did not include quota headers"); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + } + } + + throw lastError ?? new Error("Failed to fetch quotas"); +} From b5a850f153956c9629f21dd01fd26ca09d7584a1 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 16:16:33 +0800 Subject: [PATCH 102/376] refactor: extract account check state --- index.ts | 104 ++++++++++++++--------------- lib/runtime/account-check-types.ts | 26 ++++++++ 2 files changed, 78 insertions(+), 52 deletions(-) create mode 100644 lib/runtime/account-check-types.ts diff --git a/index.ts b/index.ts index 227de586..c65c0cdb 100644 --- a/index.ts +++ b/index.ts @@ -160,6 +160,10 @@ import { applyFastSessionDefaults } from "./lib/request/request-transformer.js"; import { isEmptyResponse } from "./lib/request/response-handler.js"; import { withStreamingFailover } from "./lib/request/stream-failover.js"; import { addJitter } from "./lib/rotation.js"; +import { + type AccountCheckWorkingState, + createAccountCheckWorkingState, +} from "./lib/runtime/account-check-types.js"; import { invalidateRuntimeAccountManagerCache, reloadRuntimeAccountManager, @@ -190,12 +194,12 @@ import { } from "./lib/runtime/metrics.js"; import { runOAuthBrowserFlow } from "./lib/runtime/oauth-browser-flow.js"; import { applyRuntimePreemptiveQuotaSettings } from "./lib/runtime/preemptive-quota.js"; -import { fetchRuntimeCodexQuotaSnapshot } from "./lib/runtime/quota-probe.js"; import { type CodexQuotaSnapshot, formatCodexQuotaLine, parseCodexQuotaSnapshot, } from "./lib/runtime/quota-headers.js"; +import { fetchRuntimeCodexQuotaSnapshot } from "./lib/runtime/quota-probe.js"; import { ensureRuntimeRefreshGuardian } from "./lib/runtime/refresh-guardian.js"; import { normalizeRuntimeRequestInit, @@ -2431,21 +2435,21 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }; const fetchCodexQuotaSnapshot = async (params: { - accountId: string; - accessToken: string; - }): Promise => - fetchRuntimeCodexQuotaSnapshot({ - accountId: params.accountId, - accessToken: params.accessToken, - baseUrl: CODEX_BASE_URL, - fetchImpl: fetch, - getCodexInstructions, - createCodexHeaders, - parseCodexQuotaSnapshot, - getUnsupportedCodexModelInfo, - }); + accountId: string; + accessToken: string; + }): Promise => + fetchRuntimeCodexQuotaSnapshot({ + accountId: params.accountId, + accessToken: params.accessToken, + baseUrl: CODEX_BASE_URL, + fetchImpl: fetch, + getCodexInstructions, + createCodexHeaders, + parseCodexQuotaSnapshot, + getUnsupportedCodexModelInfo, + }); -const runAccountCheck = async ( + const runAccountCheck = async ( deepProbe: boolean, ): Promise => { const loadedStorage = await hydrateEmails(await loadAccounts()); @@ -2472,13 +2476,9 @@ const runAccountCheck = async ( } const flaggedStorage = await loadFlaggedAccounts(); - let storageChanged = false; - let flaggedChanged = false; - const removeFromActive = new Set(); + const state: AccountCheckWorkingState = + createAccountCheckWorkingState(flaggedStorage); const total = workingStorage.accounts.length; - let ok = 0; - let disabled = 0; - let errors = 0; console.log( `\nChecking ${deepProbe ? "full account health" : "quotas"} for all accounts...\n`, @@ -2490,7 +2490,7 @@ const runAccountCheck = async ( const label = account.email ?? account.accountLabel ?? `Account ${i + 1}`; if (account.enabled === false) { - disabled += 1; + state.disabled += 1; console.log(`[${i + 1}/${total}] ${label}: DISABLED`); continue; } @@ -2523,7 +2523,7 @@ const runAccountCheck = async ( ) { account.accountId = tokenAccountId; account.accountIdSource = "token"; - storageChanged = true; + state.storageChanged = true; } } @@ -2547,18 +2547,18 @@ const runAccountCheck = async ( cached.refreshToken !== account.refreshToken ) { account.refreshToken = cached.refreshToken; - storageChanged = true; + state.storageChanged = true; } if ( cached.accessToken && cached.accessToken !== account.accessToken ) { account.accessToken = cached.accessToken; - storageChanged = true; + state.storageChanged = true; } if (cached.expiresAt !== account.expiresAt) { account.expiresAt = cached.expiresAt; - storageChanged = true; + state.storageChanged = true; } const hydratedEmail = sanitizeEmail( @@ -2566,7 +2566,7 @@ const runAccountCheck = async ( ); if (hydratedEmail && hydratedEmail !== account.email) { account.email = hydratedEmail; - storageChanged = true; + state.storageChanged = true; } tokenAccountId = extractAccountId(cached.accessToken); @@ -2580,7 +2580,7 @@ const runAccountCheck = async ( ) { account.accountId = tokenAccountId; account.accountIdSource = "token"; - storageChanged = true; + state.storageChanged = true; } } } @@ -2590,7 +2590,7 @@ const runAccountCheck = async ( account.refreshToken, ); if (refreshResult.type !== "success") { - errors += 1; + state.errors += 1; const message = refreshResult.message ?? refreshResult.reason ?? @@ -2599,7 +2599,7 @@ const runAccountCheck = async ( `[${i + 1}/${total}] ${label}: ERROR (${message})`, ); if (deepProbe && isFlaggableFailure(refreshResult)) { - const existingIndex = flaggedStorage.accounts.findIndex( + const existingIndex = state.flaggedStorage.accounts.findIndex( (flagged) => flagged.refreshToken === account.refreshToken, ); @@ -2610,13 +2610,13 @@ const runAccountCheck = async ( lastError: message, }; if (existingIndex >= 0) { - flaggedStorage.accounts[existingIndex] = + state.flaggedStorage.accounts[existingIndex] = flaggedRecord; } else { - flaggedStorage.accounts.push(flaggedRecord); + state.flaggedStorage.accounts.push(flaggedRecord); } - removeFromActive.add(account.refreshToken); - flaggedChanged = true; + state.removeFromActive.add(account.refreshToken); + state.flaggedChanged = true; } continue; } @@ -2625,21 +2625,21 @@ const runAccountCheck = async ( authDetail = "OK"; if (refreshResult.refresh !== account.refreshToken) { account.refreshToken = refreshResult.refresh; - storageChanged = true; + state.storageChanged = true; } if ( refreshResult.access && refreshResult.access !== account.accessToken ) { account.accessToken = refreshResult.access; - storageChanged = true; + state.storageChanged = true; } if ( typeof refreshResult.expires === "number" && refreshResult.expires !== account.expiresAt ) { account.expiresAt = refreshResult.expires; - storageChanged = true; + state.storageChanged = true; } const hydratedEmail = sanitizeEmail( extractAccountEmail( @@ -2649,7 +2649,7 @@ const runAccountCheck = async ( ); if (hydratedEmail && hydratedEmail !== account.email) { account.email = hydratedEmail; - storageChanged = true; + state.storageChanged = true; } tokenAccountId = extractAccountId(refreshResult.access); if ( @@ -2662,7 +2662,7 @@ const runAccountCheck = async ( ) { account.accountId = tokenAccountId; account.accountIdSource = "token"; - storageChanged = true; + state.storageChanged = true; } } @@ -2671,7 +2671,7 @@ const runAccountCheck = async ( } if (deepProbe) { - ok += 1; + state.ok += 1; const detail = tokenAccountId ? `${authDetail} (id:${tokenAccountId.slice(-6)})` : authDetail; @@ -2697,12 +2697,12 @@ const runAccountCheck = async ( accountId: requestAccountId, accessToken, }); - ok += 1; + state.ok += 1; console.log( `[${i + 1}/${total}] ${label}: ${formatCodexQuotaLine(snapshot)}`, ); } catch (error) { - errors += 1; + state.errors += 1; const message = error instanceof Error ? error.message : String(error); console.log( @@ -2710,7 +2710,7 @@ const runAccountCheck = async ( ); } } catch (error) { - errors += 1; + state.errors += 1; const message = error instanceof Error ? error.message : String(error); console.log( @@ -2719,29 +2719,29 @@ const runAccountCheck = async ( } } - if (removeFromActive.size > 0) { + if (state.removeFromActive.size > 0) { workingStorage.accounts = workingStorage.accounts.filter( - (account) => !removeFromActive.has(account.refreshToken), + (account) => !state.removeFromActive.has(account.refreshToken), ); clampActiveIndices(workingStorage); - storageChanged = true; + state.storageChanged = true; } - if (storageChanged) { + if (state.storageChanged) { await saveAccounts(workingStorage); invalidateAccountManagerCache(); } - if (flaggedChanged) { - await saveFlaggedAccounts(flaggedStorage); + if (state.flaggedChanged) { + await saveFlaggedAccounts(state.flaggedStorage); } console.log(""); console.log( - `Results: ${ok} ok, ${errors} error, ${disabled} disabled`, + `Results: ${state.ok} ok, ${state.errors} error, ${state.disabled} disabled`, ); - if (removeFromActive.size > 0) { + if (state.removeFromActive.size > 0) { console.log( - `Moved ${removeFromActive.size} account(s) to flagged pool (invalid refresh token).`, + `Moved ${state.removeFromActive.size} account(s) to flagged pool (invalid refresh token).`, ); } console.log(""); diff --git a/lib/runtime/account-check-types.ts b/lib/runtime/account-check-types.ts new file mode 100644 index 00000000..0b46edca --- /dev/null +++ b/lib/runtime/account-check-types.ts @@ -0,0 +1,26 @@ +import type { FlaggedAccountMetadataV1 } from "../storage.js"; + +export type AccountCheckWorkingState = { + storageChanged: boolean; + flaggedChanged: boolean; + ok: number; + errors: number; + disabled: number; + removeFromActive: Set; + flaggedStorage: { version: 1; accounts: FlaggedAccountMetadataV1[] }; +}; + +export function createAccountCheckWorkingState(flaggedStorage: { + version: 1; + accounts: FlaggedAccountMetadataV1[]; +}): AccountCheckWorkingState { + return { + storageChanged: false, + flaggedChanged: false, + ok: 0, + errors: 0, + disabled: 0, + removeFromActive: new Set(), + flaggedStorage, + }; +} From 0a48d42da5a69d78c9815a4c6eb12d937e7dcfe4 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 16:19:51 +0800 Subject: [PATCH 103/376] refactor: extract flagged verification state --- index.ts | 32 +++++++++++++++-------------- lib/runtime/flagged-verify-types.ts | 14 +++++++++++++ 2 files changed, 31 insertions(+), 15 deletions(-) create mode 100644 lib/runtime/flagged-verify-types.ts diff --git a/index.ts b/index.ts index c65c0cdb..3154bfc6 100644 --- a/index.ts +++ b/index.ts @@ -182,6 +182,7 @@ import { } from "./lib/runtime/account-state.js"; import { buildCapabilityBoostByAccount } from "./lib/runtime/capability-boost.js"; import { createRuntimeEventHandler } from "./lib/runtime/event-handler.js"; +import { createFlaggedVerificationState } from "./lib/runtime/flagged-verify-types.js"; import { ensureRuntimeLiveAccountSync } from "./lib/runtime/live-sync.js"; import { buildManualOAuthFlow } from "./lib/runtime/manual-oauth-flow.js"; import { @@ -2599,10 +2600,11 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { `[${i + 1}/${total}] ${label}: ERROR (${message})`, ); if (deepProbe && isFlaggableFailure(refreshResult)) { - const existingIndex = state.flaggedStorage.accounts.findIndex( - (flagged) => - flagged.refreshToken === account.refreshToken, - ); + const existingIndex = + state.flaggedStorage.accounts.findIndex( + (flagged) => + flagged.refreshToken === account.refreshToken, + ); const flaggedRecord: FlaggedAccountMetadataV1 = { ...account, flaggedAt: Date.now(), @@ -2721,7 +2723,8 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { if (state.removeFromActive.size > 0) { workingStorage.accounts = workingStorage.accounts.filter( - (account) => !state.removeFromActive.has(account.refreshToken), + (account) => + !state.removeFromActive.has(account.refreshToken), ); clampActiveIndices(workingStorage); state.storageChanged = true; @@ -2755,8 +2758,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } console.log("\nVerifying flagged accounts...\n"); - const remaining: FlaggedAccountMetadataV1[] = []; - const restored: TokenSuccessWithAccount[] = []; + const state = createFlaggedVerificationState(); for (let i = 0; i < flaggedStorage.accounts.length; i += 1) { const flagged = flaggedStorage.accounts[i]; @@ -2797,7 +2799,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { if (!resolved.accountLabel && flagged.accountLabel) { resolved.accountLabel = flagged.accountLabel; } - restored.push(resolved); + state.restored.push(resolved); console.log( `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: RESTORED (Codex CLI cache)`, ); @@ -2811,7 +2813,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { console.log( `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: STILL FLAGGED (${refreshResult.message ?? refreshResult.reason ?? "refresh failed"})`, ); - remaining.push(flagged); + state.remaining.push(flagged); continue; } @@ -2826,7 +2828,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { if (!resolved.accountLabel && flagged.accountLabel) { resolved.accountLabel = flagged.accountLabel; } - restored.push(resolved); + state.restored.push(resolved); console.log( `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: RESTORED`, ); @@ -2836,26 +2838,26 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { console.log( `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: ERROR (${message.slice(0, 120)})`, ); - remaining.push({ + state.remaining.push({ ...flagged, lastError: message, }); } } - if (restored.length > 0) { - await persistAccounts(restored, false); + if (state.restored.length > 0) { + await persistAccounts(state.restored, false); invalidateAccountManagerCache(); } await saveFlaggedAccounts({ version: 1, - accounts: remaining, + accounts: state.remaining, }); console.log(""); console.log( - `Results: ${restored.length} restored, ${remaining.length} still flagged`, + `Results: ${state.restored.length} restored, ${state.remaining.length} still flagged`, ); console.log(""); }; diff --git a/lib/runtime/flagged-verify-types.ts b/lib/runtime/flagged-verify-types.ts new file mode 100644 index 00000000..b788f93c --- /dev/null +++ b/lib/runtime/flagged-verify-types.ts @@ -0,0 +1,14 @@ +import type { FlaggedAccountMetadataV1 } from "../storage.js"; +import type { TokenSuccessWithAccount } from "./account-selection.js"; + +export type FlaggedVerificationState = { + remaining: FlaggedAccountMetadataV1[]; + restored: TokenSuccessWithAccount[]; +}; + +export function createFlaggedVerificationState(): FlaggedVerificationState { + return { + remaining: [], + restored: [], + }; +} From 99e8f18c052773c75056fcb68af1a3aec6292826 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 16:22:12 +0800 Subject: [PATCH 104/376] fix: harden runtime live sync cleanup --- index.ts | 4 + lib/runtime/live-sync.ts | 26 ++++- test/runtime-live-sync.test.ts | 206 +++++++++++++++++++++++++++++++++ 3 files changed, 230 insertions(+), 6 deletions(-) create mode 100644 test/runtime-live-sync.test.ts diff --git a/index.ts b/index.ts index 2a70bcf4..0cdc2d05 100644 --- a/index.ts +++ b/index.ts @@ -259,6 +259,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { let perProjectStorageWarningShown = false; let liveAccountSync: LiveAccountSync | null = null; let liveAccountSyncPath: string | null = null; + let liveAccountSyncCleanupRegistered = false; let refreshGuardian: RefreshGuardian | null = null; let refreshGuardianConfigKey: string | null = null; let sessionAffinityStore: SessionAffinityStore | null = @@ -468,6 +469,8 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { getStoragePath, currentSync: liveAccountSync, currentPath: liveAccountSyncPath, + currentCleanupRegistered: liveAccountSyncCleanupRegistered, + getCurrentSync: () => liveAccountSync, createSync: (onChange, options) => new LiveAccountSync(onChange, options), reloadAccountManagerFromDisk, getLiveAccountSyncDebounceMs, @@ -478,6 +481,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }); liveAccountSync = ensured.sync; liveAccountSyncPath = ensured.path; + liveAccountSyncCleanupRegistered = ensured.cleanupRegistered; }; const ensureRefreshGuardian = ( diff --git a/lib/runtime/live-sync.ts b/lib/runtime/live-sync.ts index 3ab38769..a21da320 100644 --- a/lib/runtime/live-sync.ts +++ b/lib/runtime/live-sync.ts @@ -15,6 +15,8 @@ export async function ensureRuntimeLiveAccountSync< getStoragePath: () => string; currentSync: TSync | null; currentPath: string | null; + currentCleanupRegistered: boolean; + getCurrentSync: () => TSync | null; createSync: ( onChange: () => Promise, options: { debounceMs: number; pollIntervalMs: number }, @@ -27,14 +29,23 @@ export async function ensureRuntimeLiveAccountSync< registerCleanup: (cleanup: () => void) => void; logWarn: (message: string) => void; pluginName: string; -}): Promise<{ sync: TSync | null; path: string | null }> { +}): Promise<{ + sync: TSync | null; + path: string | null; + cleanupRegistered: boolean; +}> { if (!deps.getLiveAccountSync(deps.pluginConfig)) { deps.currentSync?.stop(); - return { sync: null, path: null }; + return { + sync: null, + path: null, + cleanupRegistered: deps.currentCleanupRegistered, + }; } const targetPath = deps.getStoragePath(); let sync = deps.currentSync; + let cleanupRegistered = deps.currentCleanupRegistered; if (!sync) { sync = deps.createSync( async () => { @@ -45,9 +56,12 @@ export async function ensureRuntimeLiveAccountSync< pollIntervalMs: deps.getLiveAccountSyncPollMs(deps.pluginConfig), }, ); - deps.registerCleanup(() => { - sync?.stop(); - }); + if (!cleanupRegistered) { + deps.registerCleanup(() => { + deps.getCurrentSync()?.stop(); + }); + cleanupRegistered = true; + } } let nextPath = deps.currentPath; @@ -72,5 +86,5 @@ export async function ensureRuntimeLiveAccountSync< } } - return { sync, path: nextPath }; + return { sync, path: nextPath, cleanupRegistered }; } diff --git a/test/runtime-live-sync.test.ts b/test/runtime-live-sync.test.ts new file mode 100644 index 00000000..096862d0 --- /dev/null +++ b/test/runtime-live-sync.test.ts @@ -0,0 +1,206 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ensureRuntimeLiveAccountSync } from "../lib/runtime/live-sync.js"; + +describe("runtime live sync", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + function createDeps(overrides: { + liveAccountSyncEnabled?: boolean; + currentSync?: { + stop: ReturnType; + syncToPath: ReturnType; + } | null; + currentPath?: string | null; + currentCleanupRegistered?: boolean; + targetPath?: string; + pluginName?: string; + } = {}) { + let liveSync = overrides.currentSync ?? null; + const registerCleanup = vi.fn(); + const createSync = vi.fn((_onChange, _options) => ({ + stop: vi.fn(), + syncToPath: vi.fn().mockResolvedValue(undefined), + })); + const deps = { + pluginConfig: {}, + authFallback: undefined, + getLiveAccountSync: vi + .fn() + .mockReturnValue(overrides.liveAccountSyncEnabled ?? true), + getStoragePath: vi + .fn() + .mockReturnValue(overrides.targetPath ?? "C:\\repo\\accounts.json"), + currentSync: overrides.currentSync ?? null, + currentPath: overrides.currentPath ?? null, + currentCleanupRegistered: overrides.currentCleanupRegistered ?? false, + getCurrentSync: () => liveSync, + createSync, + reloadAccountManagerFromDisk: vi.fn().mockResolvedValue(undefined), + getLiveAccountSyncDebounceMs: vi.fn().mockReturnValue(25), + getLiveAccountSyncPollMs: vi.fn().mockReturnValue(250), + registerCleanup, + logWarn: vi.fn(), + pluginName: overrides.pluginName ?? "codex-multi-auth", + }; + + return { + deps, + createSync, + registerCleanup, + setLiveSync(value: typeof liveSync) { + liveSync = value; + }, + }; + } + + afterEach(() => { + vi.useRealTimers(); + }); + + it("stops the active sync when live sync is disabled", async () => { + const currentSync = { + stop: vi.fn(), + syncToPath: vi.fn(), + }; + const { deps } = createDeps({ + liveAccountSyncEnabled: false, + currentSync, + currentPath: "C:\\repo\\accounts.json", + currentCleanupRegistered: true, + }); + + await expect(ensureRuntimeLiveAccountSync(deps)).resolves.toEqual({ + sync: null, + path: null, + cleanupRegistered: true, + }); + expect(currentSync.stop).toHaveBeenCalledTimes(1); + }); + + it("creates a sync, registers cleanup once, and skips redundant path switches", async () => { + const { deps, createSync, registerCleanup, setLiveSync } = createDeps(); + + const first = await ensureRuntimeLiveAccountSync(deps); + setLiveSync(first.sync); + + expect(createSync).toHaveBeenCalledTimes(1); + expect(registerCleanup).toHaveBeenCalledTimes(1); + expect(first.path).toBe("C:\\repo\\accounts.json"); + expect(first.cleanupRegistered).toBe(true); + expect(first.sync?.syncToPath).toHaveBeenCalledWith( + "C:\\repo\\accounts.json", + ); + + const second = await ensureRuntimeLiveAccountSync({ + ...deps, + currentSync: first.sync, + currentPath: first.path, + currentCleanupRegistered: first.cleanupRegistered, + }); + + expect(second.sync).toBe(first.sync); + expect(createSync).toHaveBeenCalledTimes(1); + expect(registerCleanup).toHaveBeenCalledTimes(1); + expect(first.sync?.syncToPath).toHaveBeenCalledTimes(1); + }); + + it("retries EPERM path switches with exponential backoff before succeeding", async () => { + const currentSync = { + stop: vi.fn(), + syncToPath: vi + .fn() + .mockRejectedValueOnce(Object.assign(new Error("locked"), { code: "EPERM" })) + .mockRejectedValueOnce(Object.assign(new Error("still-locked"), { code: "EBUSY" })) + .mockResolvedValueOnce(undefined), + }; + const { deps } = createDeps({ + currentSync, + currentPath: "C:\\repo\\old.json", + targetPath: "C:\\repo\\new.json", + currentCleanupRegistered: true, + }); + + const pending = ensureRuntimeLiveAccountSync(deps); + await vi.advanceTimersByTimeAsync(25); + await vi.advanceTimersByTimeAsync(50); + await expect(pending).resolves.toMatchObject({ + sync: currentSync, + path: "C:\\repo\\new.json", + cleanupRegistered: true, + }); + expect(currentSync.syncToPath).toHaveBeenCalledTimes(3); + }); + + it("logs a warning and keeps the previous path after exhausting transient lock retries", async () => { + const currentSync = { + stop: vi.fn(), + syncToPath: vi + .fn() + .mockRejectedValue(Object.assign(new Error("locked"), { code: "EBUSY" })), + }; + const { deps } = createDeps({ + currentSync, + currentPath: "C:\\repo\\old.json", + targetPath: "C:\\repo\\new.json", + currentCleanupRegistered: true, + pluginName: "test-plugin", + }); + + const pending = ensureRuntimeLiveAccountSync(deps); + await vi.advanceTimersByTimeAsync(25 + 50 + 100); + await expect(pending).resolves.toMatchObject({ + sync: currentSync, + path: "C:\\repo\\old.json", + cleanupRegistered: true, + }); + expect(currentSync.syncToPath).toHaveBeenCalledTimes(3); + expect(deps.logWarn).toHaveBeenCalledWith( + expect.stringContaining("[test-plugin]"), + ); + }); + + it("rethrows non-transient syncToPath errors immediately", async () => { + const currentSync = { + stop: vi.fn(), + syncToPath: vi.fn().mockRejectedValue(new Error("boom")), + }; + const { deps } = createDeps({ + currentSync, + currentPath: "C:\\repo\\old.json", + targetPath: "C:\\repo\\new.json", + currentCleanupRegistered: true, + }); + + await expect(ensureRuntimeLiveAccountSync(deps)).rejects.toThrow("boom"); + expect(deps.logWarn).not.toHaveBeenCalled(); + }); + + it("does not accumulate cleanup handlers when sync is toggled off and on again", async () => { + const { deps, registerCleanup, setLiveSync } = createDeps(); + + const first = await ensureRuntimeLiveAccountSync(deps); + setLiveSync(first.sync); + expect(registerCleanup).toHaveBeenCalledTimes(1); + + const disabled = await ensureRuntimeLiveAccountSync({ + ...deps, + currentSync: first.sync, + currentPath: first.path, + currentCleanupRegistered: first.cleanupRegistered, + getLiveAccountSync: vi.fn().mockReturnValue(false), + }); + setLiveSync(disabled.sync); + + const reenabled = await ensureRuntimeLiveAccountSync({ + ...deps, + currentSync: disabled.sync, + currentPath: disabled.path, + currentCleanupRegistered: disabled.cleanupRegistered, + }); + setLiveSync(reenabled.sync); + + expect(registerCleanup).toHaveBeenCalledTimes(1); + }); +}); From f2e5116039bcea77494868ffd2c5f1d484c00229 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 16:22:12 +0800 Subject: [PATCH 105/376] fix: cover manual oauth runtime helper --- index.ts | 1 + lib/runtime/manual-oauth-flow.ts | 2 +- test/runtime-manual-oauth-flow.test.ts | 157 +++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 test/runtime-manual-oauth-flow.test.ts diff --git a/index.ts b/index.ts index 95f62fad..f02ae594 100644 --- a/index.ts +++ b/index.ts @@ -3995,6 +3995,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { onSuccess: async (tokens: TokenSuccessWithAccount) => { try { await persistAccountPool([tokens], false); + invalidateAccountManagerCache(); } catch (err) { const storagePath = getStoragePath(); const errorCode = diff --git a/lib/runtime/manual-oauth-flow.ts b/lib/runtime/manual-oauth-flow.ts index 55666b12..ffd5aa05 100644 --- a/lib/runtime/manual-oauth-flow.ts +++ b/lib/runtime/manual-oauth-flow.ts @@ -38,7 +38,7 @@ export function buildManualOAuthFlow( }, callback: async ( input: string, - ): Promise => { + ): Promise => { const parsed = parseAuthorizationInput(input); if (!parsed.code || !parsed.state) { return { diff --git a/test/runtime-manual-oauth-flow.test.ts b/test/runtime-manual-oauth-flow.test.ts new file mode 100644 index 00000000..810d86e0 --- /dev/null +++ b/test/runtime-manual-oauth-flow.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + exchangeAuthorizationCode: vi.fn(), + resolveAccountSelection: vi.fn(), +})); + +vi.mock("../lib/auth/auth.js", async () => { + const actual = await vi.importActual( + "../lib/auth/auth.js", + ); + return { + ...actual, + exchangeAuthorizationCode: mocks.exchangeAuthorizationCode, + }; +}); + +vi.mock("../lib/runtime/account-selection.js", async () => { + const actual = await vi.importActual< + typeof import("../lib/runtime/account-selection.js") + >("../lib/runtime/account-selection.js"); + return { + ...actual, + resolveAccountSelection: mocks.resolveAccountSelection, + }; +}); + +import { REDIRECT_URI } from "../lib/auth/auth.js"; +import { buildManualOAuthFlow } from "../lib/runtime/manual-oauth-flow.js"; +import type { TokenSuccessWithAccount } from "../lib/runtime/account-selection.js"; +import type { TokenResult } from "../lib/types.js"; + +describe("runtime manual oauth flow", () => { + const pkce = { verifier: "pkce-verifier" }; + const expectedState = "state-123"; + + beforeEach(() => { + mocks.exchangeAuthorizationCode.mockReset(); + mocks.resolveAccountSelection.mockReset(); + }); + + function createFlow( + overrides: { + onSuccess?: (tokens: TokenSuccessWithAccount) => Promise; + logInfo?: (message: string) => void; + } = {}, + ) { + return buildManualOAuthFlow(pkce, "https://example.com/auth", expectedState, { + instructions: "Paste the callback URL", + logInfo: overrides.logInfo ?? vi.fn(), + onSuccess: overrides.onSuccess, + }); + } + + it("validates missing code, missing state, mismatched state, and query-string pastes", () => { + const flow = createFlow(); + + expect( + flow.validate( + `http://127.0.0.1:1455/auth/callback?state=${expectedState}`, + ), + ).toContain( + "No authorization code found.", + ); + expect(flow.validate("?code=abc123")).toBe( + "Missing OAuth state. Paste the full callback URL including both code and state parameters.", + ); + expect(flow.validate("?code=abc123&state=wrong-state")).toBe( + "OAuth state mismatch. Restart login and paste the callback URL generated for this login attempt.", + ); + expect(flow.validate(`?code=abc123&state=${expectedState}`)).toBeUndefined(); + }); + + it("returns invalid_response failures when callback input is malformed or mismatched", async () => { + const flow = createFlow(); + + await expect(flow.callback("?code=missing-state")).resolves.toEqual({ + type: "failed", + reason: "invalid_response", + message: "Missing authorization code or OAuth state", + }); + await expect(flow.callback("?code=abc123&state=wrong-state")).resolves.toEqual( + { + type: "failed", + reason: "invalid_response", + message: "OAuth state mismatch. Restart login and try again.", + }, + ); + expect(mocks.exchangeAuthorizationCode).not.toHaveBeenCalled(); + expect(mocks.resolveAccountSelection).not.toHaveBeenCalled(); + }); + + it("exchanges tokens, resolves account selection, and awaits onSuccess", async () => { + const tokenResult: TokenResult = { + type: "success", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + multiAccount: true, + }; + const resolvedTokens: TokenSuccessWithAccount = { + ...tokenResult, + accountIdOverride: "acct-123", + accountIdSource: "token", + accountLabel: "Primary workspace", + }; + const order: string[] = []; + + mocks.exchangeAuthorizationCode.mockResolvedValue(tokenResult); + mocks.resolveAccountSelection.mockImplementation((tokens: TokenResult) => { + order.push("resolve"); + expect(tokens).toEqual(tokenResult); + return resolvedTokens; + }); + + const flow = createFlow({ + onSuccess: async (tokens) => { + order.push("onSuccess:start"); + expect(tokens).toEqual(resolvedTokens); + await Promise.resolve(); + order.push("onSuccess:end"); + }, + }); + + await expect( + flow.callback(`?code=callback-code&state=${expectedState}`), + ).resolves.toEqual(resolvedTokens); + expect(mocks.exchangeAuthorizationCode).toHaveBeenCalledWith( + "callback-code", + pkce.verifier, + REDIRECT_URI, + ); + expect(order).toEqual(["resolve", "onSuccess:start", "onSuccess:end"]); + }); + + it("returns explicit failed results from token exchange and falls back when it returns nothing", async () => { + const failedResult: TokenResult = { + type: "failed", + reason: "http_error", + message: "bad request", + statusCode: 400, + }; + const flow = createFlow(); + + mocks.exchangeAuthorizationCode.mockResolvedValueOnce(failedResult); + await expect( + flow.callback(`?code=callback-code&state=${expectedState}`), + ).resolves.toEqual(failedResult); + + mocks.exchangeAuthorizationCode.mockResolvedValueOnce(undefined); + await expect( + flow.callback(`http://127.0.0.1:1455/auth/callback?code=callback-code&state=${expectedState}`), + ).resolves.toEqual({ type: "failed" }); + + expect(mocks.resolveAccountSelection).not.toHaveBeenCalled(); + }); +}); From daca1dd64d5d8054c93322569b63dc472815e09d Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 16:22:12 +0800 Subject: [PATCH 106/376] test: cover runtime account scope helper --- test/runtime-account-scope.test.ts | 112 +++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 test/runtime-account-scope.test.ts diff --git a/test/runtime-account-scope.test.ts b/test/runtime-account-scope.test.ts new file mode 100644 index 00000000..6e5b7386 --- /dev/null +++ b/test/runtime-account-scope.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi } from "vitest"; +import { applyAccountStorageScope } from "../lib/runtime/account-scope.js"; + +describe("runtime account scope", () => { + function createDeps(overrides: { + perProjectAccounts?: boolean; + storageBackupEnabled?: boolean; + codexCliSyncEnabled?: boolean; + cwd?: string; + } = {}) { + const order: string[] = []; + return { + order, + deps: { + getPerProjectAccounts: vi + .fn() + .mockReturnValue(overrides.perProjectAccounts ?? false), + getStorageBackupEnabled: vi + .fn() + .mockReturnValue(overrides.storageBackupEnabled ?? true), + isCodexCliSyncEnabled: vi + .fn() + .mockReturnValue(overrides.codexCliSyncEnabled ?? false), + setStorageBackupEnabled: vi.fn((enabled: boolean) => { + order.push(`backup:${enabled}`); + }), + setStoragePath: vi.fn((path: string | null) => { + order.push(`path:${path ?? "null"}`); + }), + getCwd: vi + .fn() + .mockReturnValue(overrides.cwd ?? "C:\\repo\\linked-worktree"), + warnPerProjectSyncConflict: vi.fn(() => { + order.push("warn"); + }), + }, + }; + } + + it("warns and forces global storage when CLI sync is enabled for per-project accounts", () => { + const { deps, order } = createDeps({ + perProjectAccounts: true, + storageBackupEnabled: false, + codexCliSyncEnabled: true, + }); + + applyAccountStorageScope({}, deps); + + expect(deps.setStorageBackupEnabled).toHaveBeenCalledWith(false); + expect(deps.warnPerProjectSyncConflict).toHaveBeenCalledTimes(1); + expect(deps.setStoragePath).toHaveBeenCalledWith(null); + expect(order).toEqual(["backup:false", "warn", "path:null"]); + }); + + it("uses global storage without warning when CLI sync is enabled for shared accounts", () => { + const { deps, order } = createDeps({ + perProjectAccounts: false, + storageBackupEnabled: true, + codexCliSyncEnabled: true, + }); + + applyAccountStorageScope({}, deps); + + expect(deps.warnPerProjectSyncConflict).not.toHaveBeenCalled(); + expect(deps.setStoragePath).toHaveBeenCalledWith(null); + expect(order).toEqual(["backup:true", "path:null"]); + }); + + it("targets a Windows-style cwd when per-project storage is enabled without CLI sync", () => { + const { deps, order } = createDeps({ + perProjectAccounts: true, + codexCliSyncEnabled: false, + cwd: "C:\\repo\\wt\\feature", + }); + + applyAccountStorageScope({}, deps); + + expect(deps.setStoragePath).toHaveBeenCalledWith("C:\\repo\\wt\\feature"); + expect(order).toEqual(["backup:true", "path:C:\\repo\\wt\\feature"]); + }); + + it("falls back to the shared storage path when per-project storage is disabled", () => { + const { deps, order } = createDeps({ + perProjectAccounts: false, + codexCliSyncEnabled: false, + }); + + applyAccountStorageScope({}, deps); + + expect(deps.warnPerProjectSyncConflict).not.toHaveBeenCalled(); + expect(deps.setStoragePath).toHaveBeenCalledWith(null); + expect(order).toEqual(["backup:true", "path:null"]); + }); + + it("keeps backup updates ahead of path writes across repeated calls", () => { + const { deps, order } = createDeps({ + perProjectAccounts: true, + codexCliSyncEnabled: false, + cwd: "C:\\repo\\wt\\repeated", + }); + + applyAccountStorageScope({}, deps); + applyAccountStorageScope({}, deps); + + expect(order).toEqual([ + "backup:true", + "path:C:\\repo\\wt\\repeated", + "backup:true", + "path:C:\\repo\\wt\\repeated", + ]); + }); +}); From 7c6ac61ad22e9b780eaa92278f94e767287afcf4 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 16:22:32 +0800 Subject: [PATCH 107/376] fix: cover runtime request init helpers --- lib/runtime/account-manager-cache.ts | 2 + lib/runtime/account-select-event.ts | 2 +- test/runtime-request-init.test.ts | 303 +++++++++++++++++++++++++++ 3 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 test/runtime-request-init.test.ts diff --git a/lib/runtime/account-manager-cache.ts b/lib/runtime/account-manager-cache.ts index 8094e739..995b6111 100644 --- a/lib/runtime/account-manager-cache.ts +++ b/lib/runtime/account-manager-cache.ts @@ -16,6 +16,8 @@ export async function reloadRuntimeAccountManager(deps: { setReloadInFlight: (value: Promise | null) => void; authFallback?: OAuthAuthDetails; }): Promise { + // The caller must pass a fresh snapshot of the shared in-flight promise. + // Dedup only holds if setReloadInFlight runs before any awaited work below. if (deps.currentReloadInFlight) { return deps.currentReloadInFlight; } diff --git a/lib/runtime/account-select-event.ts b/lib/runtime/account-select-event.ts index acdac97b..9ee2c2e4 100644 --- a/lib/runtime/account-select-event.ts +++ b/lib/runtime/account-select-event.ts @@ -35,7 +35,7 @@ export async function handleAccountSelectEvent(input: { props.provider !== "openai" && props.provider !== input.providerId ) { - return true; + return false; } const index = props.index ?? props.accountIndex; diff --git a/test/runtime-request-init.test.ts b/test/runtime-request-init.test.ts new file mode 100644 index 00000000..0d729107 --- /dev/null +++ b/test/runtime-request-init.test.ts @@ -0,0 +1,303 @@ +import { describe, expect, it, vi } from "vitest"; +import type { UiRuntimeOptions } from "../lib/ui/runtime.js"; +import { + applyRuntimeUiOptions, + resolveRuntimeUiOptions, +} from "../lib/runtime/ui-runtime.js"; +import { getRuntimeStatusMarker } from "../lib/runtime/status-marker.js"; +import { applyRuntimePreemptiveQuotaSettings } from "../lib/runtime/preemptive-quota.js"; +import { + invalidateRuntimeAccountManagerCache, + reloadRuntimeAccountManager, +} from "../lib/runtime/account-manager-cache.js"; +import { handleAccountSelectEvent } from "../lib/runtime/account-select-event.js"; +import { + normalizeRuntimeRequestInit, + parseRuntimeRequestBody, +} from "../lib/runtime/request-init.js"; + +describe("runtime request init helpers", () => { + it("returns the provided RequestInit unchanged", async () => { + const requestInit = { method: "PATCH", body: "{\"ok\":true}" }; + + await expect( + normalizeRuntimeRequestInit( + new Request("https://example.com"), + requestInit, + ), + ).resolves.toBe(requestInit); + }); + + it("normalizes Request bodies and leaves string inputs untouched", async () => { + const request = new Request("https://example.com", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{\"hello\":\"world\"}", + }); + + await expect( + normalizeRuntimeRequestInit(request, undefined), + ).resolves.toMatchObject({ + method: "POST", + body: "{\"hello\":\"world\"}", + }); + await expect( + normalizeRuntimeRequestInit("https://example.com", undefined), + ).resolves.toBeUndefined(); + }); + + it("falls back when the Request body cannot be re-read", async () => { + const request = new Request("https://example.com", { + method: "POST", + body: "{\"hello\":\"world\"}", + }); + await request.text(); + + await expect( + normalizeRuntimeRequestInit(request, undefined), + ).resolves.toEqual({ + method: "POST", + headers: expect.any(Headers), + }); + }); + + it("parses string, typed-array, buffer, view, and blob payloads", async () => { + const logWarn = vi.fn(); + const json = "{\"value\":42}"; + const bytes = new TextEncoder().encode(json); + const view = new DataView(bytes.buffer.slice(0)); + + await expect(parseRuntimeRequestBody(json, { logWarn })).resolves.toEqual({ + value: 42, + }); + await expect(parseRuntimeRequestBody(bytes, { logWarn })).resolves.toEqual({ + value: 42, + }); + await expect( + parseRuntimeRequestBody(bytes.buffer.slice(0), { logWarn }), + ).resolves.toEqual({ + value: 42, + }); + await expect(parseRuntimeRequestBody(view, { logWarn })).resolves.toEqual({ + value: 42, + }); + await expect( + parseRuntimeRequestBody(new Blob([json]), { logWarn }), + ).resolves.toEqual({ + value: 42, + }); + expect(logWarn).not.toHaveBeenCalled(); + }); + + it("logs a warning and returns an empty object when parsing fails", async () => { + const logWarn = vi.fn(); + + await expect( + parseRuntimeRequestBody("{\"broken\":", { logWarn }), + ).resolves.toEqual({}); + + expect(logWarn).toHaveBeenCalledWith( + "Failed to parse request body, using empty object", + ); + }); +}); + +describe("runtime account manager cache helpers", () => { + it("invalidates the cached manager and promise", () => { + const setCachedAccountManager = vi.fn(); + const setAccountManagerPromise = vi.fn(); + + invalidateRuntimeAccountManagerCache({ + setCachedAccountManager, + setAccountManagerPromise, + }); + + expect(setCachedAccountManager).toHaveBeenCalledWith(null); + expect(setAccountManagerPromise).toHaveBeenCalledWith(null); + }); + + it("deduplicates reloads through the shared in-flight promise", async () => { + let releaseLoad: ((value: { id: string }) => void) | undefined; + let currentReloadInFlight: Promise<{ id: string }> | null = null; + const loadFromDisk = vi.fn( + () => + new Promise<{ id: string }>((resolve) => { + releaseLoad = resolve; + }), + ); + const setReloadInFlight = vi.fn( + (value: Promise<{ id: string }> | null) => { + currentReloadInFlight = value; + }, + ); + + const first = reloadRuntimeAccountManager({ + currentReloadInFlight, + loadFromDisk, + setCachedAccountManager: vi.fn(), + setAccountManagerPromise: vi.fn(), + setReloadInFlight, + }); + const second = reloadRuntimeAccountManager({ + currentReloadInFlight, + loadFromDisk, + setCachedAccountManager: vi.fn(), + setAccountManagerPromise: vi.fn(), + setReloadInFlight, + }); + + expect(loadFromDisk).toHaveBeenCalledTimes(1); + releaseLoad?.({ id: "manager-1" }); + await expect(Promise.all([first, second])).resolves.toEqual([ + { id: "manager-1" }, + { id: "manager-1" }, + ]); + expect(setReloadInFlight).toHaveBeenLastCalledWith(null); + expect(currentReloadInFlight).toBeNull(); + }); +}); + +describe("runtime account select event helper", () => { + const storage = { + version: 3 as const, + accounts: [ + { + refreshToken: "refresh-1", + addedAt: 1, + lastUsed: 1, + rateLimitResetTimes: {}, + enabled: true, + }, + ], + activeIndex: 0, + activeIndexByFamily: {}, + }; + + it("returns false when the provider filter skips the event", async () => { + const loadAccounts = vi.fn(); + + await expect( + handleAccountSelectEvent({ + event: { + type: "account.select", + properties: { index: 0, provider: "other-provider" }, + }, + providerId: "openai", + loadAccounts, + saveAccounts: vi.fn(), + modelFamilies: [], + cachedAccountManager: null, + reloadAccountManagerFromDisk: vi.fn(), + setLastCodexCliActiveSyncIndex: vi.fn(), + showToast: vi.fn(), + }), + ).resolves.toBe(false); + expect(loadAccounts).not.toHaveBeenCalled(); + }); + + it("updates storage and reports handled events for matching providers", async () => { + const saveAccounts = vi.fn(); + const setLastCodexCliActiveSyncIndex = vi.fn(); + const showToast = vi.fn(); + + await expect( + handleAccountSelectEvent({ + event: { + type: "account.select", + properties: { index: 0, provider: "openai" }, + }, + providerId: "openai", + loadAccounts: vi.fn().mockResolvedValue(structuredClone(storage)), + saveAccounts, + modelFamilies: ["gpt-5"], + cachedAccountManager: null, + reloadAccountManagerFromDisk: vi.fn(), + setLastCodexCliActiveSyncIndex, + showToast, + }), + ).resolves.toBe(true); + expect(saveAccounts).toHaveBeenCalledTimes(1); + expect(setLastCodexCliActiveSyncIndex).toHaveBeenCalledWith(0); + expect(showToast).toHaveBeenCalledWith("Switched to account 1", "info"); + }); +}); + +describe("runtime helper wrappers", () => { + it("configures preemptive quota settings from the plugin config", () => { + const configure = vi.fn(); + + applyRuntimePreemptiveQuotaSettings( + { settings: true }, + { + configure, + getPreemptiveQuotaEnabled: vi.fn().mockReturnValue(true), + getPreemptiveQuotaRemainingPercent5h: vi.fn().mockReturnValue(25), + getPreemptiveQuotaRemainingPercent7d: vi.fn().mockReturnValue(10), + getPreemptiveQuotaMaxDeferralMs: vi.fn().mockReturnValue(90_000), + }, + ); + + expect(configure).toHaveBeenCalledWith({ + enabled: true, + remainingPercentThresholdPrimary: 25, + remainingPercentThresholdSecondary: 10, + maxDeferralMs: 90_000, + }); + }); + + it("returns legacy and v2 status markers correctly", () => { + const ui = { + v2Enabled: true, + theme: { + glyphs: { + check: "OK", + cross: "NO", + }, + }, + } as unknown as UiRuntimeOptions; + + expect(getRuntimeStatusMarker(ui, "ok")).toBe("OK"); + expect(getRuntimeStatusMarker(ui, "warning")).toBe("!"); + expect(getRuntimeStatusMarker(ui, "error")).toBe("NO"); + expect(getRuntimeStatusMarker({ ...ui, v2Enabled: false }, "ok")).toBe( + "✓", + ); + }); + + it("applies and resolves runtime UI options", () => { + const setUiRuntimeOptions = vi.fn().mockReturnValue({ + v2Enabled: false, + colorProfile: "ansi16", + glyphMode: "unicode", + }); + const pluginConfig = { palette: "green" }; + const applyUiRuntimeFromConfig = vi.fn().mockReturnValue({ + v2Enabled: true, + colorProfile: "truecolor", + glyphMode: "ascii", + }); + + expect( + applyRuntimeUiOptions(pluginConfig, { + setUiRuntimeOptions, + getCodexTuiV2: vi.fn().mockReturnValue(false), + getCodexTuiColorProfile: vi.fn().mockReturnValue("ansi16"), + getCodexTuiGlyphMode: vi.fn().mockReturnValue("unicode"), + }), + ).toEqual({ + v2Enabled: false, + colorProfile: "ansi16", + glyphMode: "unicode", + }); + expect( + resolveRuntimeUiOptions({ + loadPluginConfig: vi.fn().mockReturnValue(pluginConfig), + applyUiRuntimeFromConfig, + }), + ).toEqual({ + v2Enabled: true, + colorProfile: "truecolor", + glyphMode: "ascii", + }); + }); +}); From 66218bbc59159a86bd8220cbb1801081a1193c4e Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 16:22:32 +0800 Subject: [PATCH 108/376] fix: harden runtime refresh guardian cleanup --- index.ts | 4 + lib/runtime/refresh-guardian.ts | 32 ++++-- test/runtime-refresh-guardian.test.ts | 148 ++++++++++++++++++++++++++ 3 files changed, 177 insertions(+), 7 deletions(-) create mode 100644 test/runtime-refresh-guardian.test.ts diff --git a/index.ts b/index.ts index 53585af7..ae0c752c 100644 --- a/index.ts +++ b/index.ts @@ -262,6 +262,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { let liveAccountSyncPath: string | null = null; let refreshGuardian: RefreshGuardian | null = null; let refreshGuardianConfigKey: string | null = null; + let refreshGuardianCleanupRegistered = false; let sessionAffinityStore: SessionAffinityStore | null = new SessionAffinityStore(); let sessionAffinityConfigKey: string | null = null; @@ -489,6 +490,8 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { getProactiveRefreshGuardian, currentGuardian: refreshGuardian, currentConfigKey: refreshGuardianConfigKey, + currentCleanupRegistered: refreshGuardianCleanupRegistered, + getCurrentGuardian: () => refreshGuardian, getProactiveRefreshIntervalMs, getProactiveRefreshBufferMs, createGuardian: ({ intervalMs, bufferMs }) => @@ -500,6 +503,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }); refreshGuardian = ensured.guardian; refreshGuardianConfigKey = ensured.configKey; + refreshGuardianCleanupRegistered = ensured.cleanupRegistered; }; const ensureSessionAffinity = ( diff --git a/lib/runtime/refresh-guardian.ts b/lib/runtime/refresh-guardian.ts index d57f479f..9ff7bca9 100644 --- a/lib/runtime/refresh-guardian.ts +++ b/lib/runtime/refresh-guardian.ts @@ -11,6 +11,8 @@ export function ensureRuntimeRefreshGuardian< getProactiveRefreshGuardian: (config: TConfig) => boolean; currentGuardian: TGuardian | null; currentConfigKey: string | null; + currentCleanupRegistered: boolean; + getCurrentGuardian: () => TGuardian | null; getProactiveRefreshIntervalMs: (config: TConfig) => number; getProactiveRefreshBufferMs: (config: TConfig) => number; createGuardian: (options: { @@ -18,24 +20,40 @@ export function ensureRuntimeRefreshGuardian< bufferMs: number; }) => TGuardian; registerCleanup: (cleanup: () => void) => void; -}): { guardian: TGuardian | null; configKey: string | null } { +}): { + guardian: TGuardian | null; + configKey: string | null; + cleanupRegistered: boolean; +} { if (!deps.getProactiveRefreshGuardian(deps.pluginConfig)) { deps.currentGuardian?.stop(); - return { guardian: null, configKey: null }; + return { + guardian: null, + configKey: null, + cleanupRegistered: deps.currentCleanupRegistered, + }; } const intervalMs = deps.getProactiveRefreshIntervalMs(deps.pluginConfig); const bufferMs = deps.getProactiveRefreshBufferMs(deps.pluginConfig); const configKey = `${intervalMs}:${bufferMs}`; if (deps.currentGuardian && deps.currentConfigKey === configKey) { - return { guardian: deps.currentGuardian, configKey: deps.currentConfigKey }; + return { + guardian: deps.currentGuardian, + configKey: deps.currentConfigKey, + cleanupRegistered: deps.currentCleanupRegistered, + }; } deps.currentGuardian?.stop(); const guardian = deps.createGuardian({ intervalMs, bufferMs }); guardian.start(); - deps.registerCleanup(() => { - guardian.stop(); - }); - return { guardian, configKey }; + let cleanupRegistered = deps.currentCleanupRegistered; + if (!cleanupRegistered) { + deps.registerCleanup(() => { + deps.getCurrentGuardian()?.stop(); + }); + cleanupRegistered = true; + } + return { guardian, configKey, cleanupRegistered }; } diff --git a/test/runtime-refresh-guardian.test.ts b/test/runtime-refresh-guardian.test.ts new file mode 100644 index 00000000..69f18d74 --- /dev/null +++ b/test/runtime-refresh-guardian.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from "vitest"; +import { ensureRuntimeRefreshGuardian } from "../lib/runtime/refresh-guardian.js"; + +describe("runtime refresh guardian", () => { + function createDeps(overrides: { + guardianEnabled?: boolean; + currentGuardian?: { start: ReturnType; stop: ReturnType } | null; + currentConfigKey?: string | null; + currentCleanupRegistered?: boolean; + intervalMs?: number; + bufferMs?: number; + } = {}) { + let currentGuardian = overrides.currentGuardian ?? null; + const registerCleanup = vi.fn(); + const createGuardian = vi.fn(({ intervalMs, bufferMs }) => ({ + start: vi.fn(), + stop: vi.fn(), + intervalMs, + bufferMs, + })); + const deps = { + pluginConfig: {}, + getProactiveRefreshGuardian: vi + .fn() + .mockReturnValue(overrides.guardianEnabled ?? true), + currentGuardian, + currentConfigKey: overrides.currentConfigKey ?? null, + currentCleanupRegistered: overrides.currentCleanupRegistered ?? false, + getCurrentGuardian: () => currentGuardian, + getProactiveRefreshIntervalMs: vi + .fn() + .mockReturnValue(overrides.intervalMs ?? 60_000), + getProactiveRefreshBufferMs: vi + .fn() + .mockReturnValue(overrides.bufferMs ?? 300_000), + createGuardian, + registerCleanup, + }; + + return { + deps, + createGuardian, + registerCleanup, + setCurrentGuardian(value: typeof currentGuardian) { + currentGuardian = value; + }, + }; + } + + it("stops and clears the current guardian when the feature is disabled", () => { + const currentGuardian = { start: vi.fn(), stop: vi.fn() }; + const { deps } = createDeps({ + guardianEnabled: false, + currentGuardian, + currentConfigKey: "60000:300000", + currentCleanupRegistered: true, + }); + + expect(ensureRuntimeRefreshGuardian(deps)).toEqual({ + guardian: null, + configKey: null, + cleanupRegistered: true, + }); + expect(currentGuardian.stop).toHaveBeenCalledTimes(1); + }); + + it("returns the existing guardian when the config is unchanged", () => { + const currentGuardian = { start: vi.fn(), stop: vi.fn() }; + const { deps, createGuardian, registerCleanup } = createDeps({ + currentGuardian, + currentConfigKey: "60000:300000", + currentCleanupRegistered: true, + }); + + expect(ensureRuntimeRefreshGuardian(deps)).toEqual({ + guardian: currentGuardian, + configKey: "60000:300000", + cleanupRegistered: true, + }); + expect(createGuardian).not.toHaveBeenCalled(); + expect(registerCleanup).not.toHaveBeenCalled(); + }); + + it("creates, starts, and registers cleanup on first creation", () => { + const { deps, createGuardian, registerCleanup, setCurrentGuardian } = createDeps(); + + const result = ensureRuntimeRefreshGuardian(deps); + setCurrentGuardian(result.guardian); + + expect(createGuardian).toHaveBeenCalledWith({ + intervalMs: 60_000, + bufferMs: 300_000, + }); + expect(result.guardian?.start).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ + configKey: "60000:300000", + cleanupRegistered: true, + }); + expect(registerCleanup).toHaveBeenCalledTimes(1); + }); + + it("replaces the guardian without registering duplicate cleanup handlers", () => { + const previousGuardian = { start: vi.fn(), stop: vi.fn() }; + const { deps, registerCleanup, setCurrentGuardian } = createDeps({ + currentGuardian: previousGuardian, + currentConfigKey: "60000:300000", + currentCleanupRegistered: true, + intervalMs: 120_000, + bufferMs: 600_000, + }); + + const result = ensureRuntimeRefreshGuardian(deps); + setCurrentGuardian(result.guardian); + + expect(previousGuardian.stop).toHaveBeenCalledTimes(1); + expect(result.guardian).not.toBe(previousGuardian); + expect(result.guardian?.start).toHaveBeenCalledTimes(1); + expect(result.configKey).toBe("120000:600000"); + expect(registerCleanup).not.toHaveBeenCalled(); + }); + + it("does not accumulate cleanup handlers across disable and re-enable cycles", () => { + const { deps, registerCleanup, setCurrentGuardian } = createDeps(); + + const first = ensureRuntimeRefreshGuardian(deps); + setCurrentGuardian(first.guardian); + expect(registerCleanup).toHaveBeenCalledTimes(1); + + const disabled = ensureRuntimeRefreshGuardian({ + ...deps, + currentGuardian: first.guardian, + currentConfigKey: first.configKey, + currentCleanupRegistered: first.cleanupRegistered, + getProactiveRefreshGuardian: vi.fn().mockReturnValue(false), + }); + setCurrentGuardian(disabled.guardian); + + const reenabled = ensureRuntimeRefreshGuardian({ + ...deps, + currentGuardian: disabled.guardian, + currentConfigKey: disabled.configKey, + currentCleanupRegistered: disabled.cleanupRegistered, + }); + setCurrentGuardian(reenabled.guardian); + + expect(registerCleanup).toHaveBeenCalledTimes(1); + }); +}); From 86ea5d67298be6ab842b4634cffc17dd04933335 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 16:22:32 +0800 Subject: [PATCH 109/376] test: cover runtime account manager cache --- lib/runtime/account-manager-cache.ts | 2 + test/runtime-account-manager-cache.test.ts | 141 +++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 test/runtime-account-manager-cache.test.ts diff --git a/lib/runtime/account-manager-cache.ts b/lib/runtime/account-manager-cache.ts index 8094e739..995b6111 100644 --- a/lib/runtime/account-manager-cache.ts +++ b/lib/runtime/account-manager-cache.ts @@ -16,6 +16,8 @@ export async function reloadRuntimeAccountManager(deps: { setReloadInFlight: (value: Promise | null) => void; authFallback?: OAuthAuthDetails; }): Promise { + // The caller must pass a fresh snapshot of the shared in-flight promise. + // Dedup only holds if setReloadInFlight runs before any awaited work below. if (deps.currentReloadInFlight) { return deps.currentReloadInFlight; } diff --git a/test/runtime-account-manager-cache.test.ts b/test/runtime-account-manager-cache.test.ts new file mode 100644 index 00000000..743719db --- /dev/null +++ b/test/runtime-account-manager-cache.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it, vi } from "vitest"; +import { + applyRuntimeUiOptions, + resolveRuntimeUiOptions, +} from "../lib/runtime/ui-runtime.js"; +import { + invalidateRuntimeAccountManagerCache, + reloadRuntimeAccountManager, +} from "../lib/runtime/account-manager-cache.js"; + +describe("runtime account manager cache", () => { + it("invalidates both cached manager setters", () => { + const setCachedAccountManager = vi.fn(); + const setAccountManagerPromise = vi.fn(); + + invalidateRuntimeAccountManagerCache({ + setCachedAccountManager, + setAccountManagerPromise, + }); + + expect(setCachedAccountManager).toHaveBeenCalledWith(null); + expect(setAccountManagerPromise).toHaveBeenCalledWith(null); + }); + + it("deduplicates concurrent reloads against the shared in-flight promise", async () => { + let releaseLoad: ((value: { id: string }) => void) | undefined; + let currentReloadInFlight: Promise<{ id: string }> | null = null; + const loadFromDisk = vi.fn( + () => + new Promise<{ id: string }>((resolve) => { + releaseLoad = resolve; + }), + ); + const setCachedAccountManager = vi.fn(); + const setAccountManagerPromise = vi.fn(); + const setReloadInFlight = vi.fn((value: Promise<{ id: string }> | null) => { + currentReloadInFlight = value; + }); + + const first = reloadRuntimeAccountManager({ + currentReloadInFlight, + loadFromDisk, + setCachedAccountManager, + setAccountManagerPromise, + setReloadInFlight, + }); + const second = reloadRuntimeAccountManager({ + currentReloadInFlight, + loadFromDisk, + setCachedAccountManager, + setAccountManagerPromise, + setReloadInFlight, + }); + + expect(loadFromDisk).toHaveBeenCalledTimes(1); + + releaseLoad?.({ id: "manager-1" }); + await expect(Promise.all([first, second])).resolves.toEqual([ + { id: "manager-1" }, + { id: "manager-1" }, + ]); + expect(setCachedAccountManager).toHaveBeenCalledWith({ id: "manager-1" }); + expect(setAccountManagerPromise).toHaveBeenCalledWith( + expect.any(Promise), + ); + expect(setReloadInFlight).toHaveBeenLastCalledWith(null); + expect(currentReloadInFlight).toBeNull(); + }); + + it("always clears the in-flight promise when a reload fails", async () => { + let currentReloadInFlight: Promise | null = null; + const setReloadInFlight = vi.fn((value: Promise | null) => { + currentReloadInFlight = value; + }); + + await expect( + reloadRuntimeAccountManager({ + currentReloadInFlight, + loadFromDisk: vi.fn().mockRejectedValue(new Error("reload failed")), + setCachedAccountManager: vi.fn(), + setAccountManagerPromise: vi.fn(), + setReloadInFlight, + }), + ).rejects.toThrow("reload failed"); + + expect(setReloadInFlight).toHaveBeenLastCalledWith(null); + expect(currentReloadInFlight).toBeNull(); + }); +}); + +describe("runtime ui resolver", () => { + it("applies runtime UI options from config-derived getters", () => { + const setUiRuntimeOptions = vi.fn().mockReturnValue({ + v2Enabled: false, + colorProfile: "ansi16", + glyphMode: "unicode", + }); + + expect( + applyRuntimeUiOptions( + { name: "config" }, + { + setUiRuntimeOptions, + getCodexTuiV2: vi.fn().mockReturnValue(false), + getCodexTuiColorProfile: vi.fn().mockReturnValue("ansi16"), + getCodexTuiGlyphMode: vi.fn().mockReturnValue("unicode"), + }, + ), + ).toEqual({ + v2Enabled: false, + colorProfile: "ansi16", + glyphMode: "unicode", + }); + expect(setUiRuntimeOptions).toHaveBeenCalledWith({ + v2Enabled: false, + colorProfile: "ansi16", + glyphMode: "unicode", + }); + }); + + it("loads plugin config and pipes it into the runtime UI resolver", () => { + const pluginConfig = { theme: "green" }; + const applyUiRuntimeFromConfig = vi.fn().mockReturnValue({ + v2Enabled: true, + colorProfile: "truecolor", + glyphMode: "ascii", + }); + + expect( + resolveRuntimeUiOptions({ + loadPluginConfig: vi.fn().mockReturnValue(pluginConfig), + applyUiRuntimeFromConfig, + }), + ).toEqual({ + v2Enabled: true, + colorProfile: "truecolor", + glyphMode: "ascii", + }); + expect(applyUiRuntimeFromConfig).toHaveBeenCalledWith(pluginConfig); + }); +}); From cd2375fb74b3d1e91e8f18ae786a10647deb49cd Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 16:24:54 +0800 Subject: [PATCH 110/376] refactor: extract runtime email hydration --- index.ts | 81 +++++------------------------- lib/runtime/hydrate-emails.ts | 92 +++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 69 deletions(-) create mode 100644 lib/runtime/hydrate-emails.ts diff --git a/index.ts b/index.ts index 3154bfc6..0f481481 100644 --- a/index.ts +++ b/index.ts @@ -183,6 +183,7 @@ import { import { buildCapabilityBoostByAccount } from "./lib/runtime/capability-boost.js"; import { createRuntimeEventHandler } from "./lib/runtime/event-handler.js"; import { createFlaggedVerificationState } from "./lib/runtime/flagged-verify-types.js"; +import { hydrateRuntimeEmails } from "./lib/runtime/hydrate-emails.js"; import { ensureRuntimeLiveAccountSync } from "./lib/runtime/live-sync.js"; import { buildManualOAuthFlow } from "./lib/runtime/manual-oauth-flow.js"; import { @@ -346,75 +347,17 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const hydrateEmails = async ( storage: AccountStorageV3 | null, - ): Promise => { - if (!storage) return storage; - const skipHydrate = - process.env.VITEST_WORKER_ID !== undefined || - process.env.NODE_ENV === "test" || - process.env.CODEX_SKIP_EMAIL_HYDRATE === "1"; - if (skipHydrate) return storage; - - const accountsCopy = storage.accounts.map((account) => - account ? { ...account } : account, - ); - const accountsToHydrate = accountsCopy.filter( - (account) => account && !account.email, - ); - if (accountsToHydrate.length === 0) return storage; - - let changed = false; - await Promise.all( - accountsToHydrate.map(async (account) => { - try { - const refreshed = await queuedRefresh(account.refreshToken); - if (refreshed.type !== "success") return; - const id = extractAccountId(refreshed.access); - const email = sanitizeEmail( - extractAccountEmail(refreshed.access, refreshed.idToken), - ); - if ( - id && - id !== account.accountId && - shouldUpdateAccountIdFromToken( - account.accountIdSource, - account.accountId, - ) - ) { - account.accountId = id; - account.accountIdSource = "token"; - changed = true; - } - if (email && email !== account.email) { - account.email = email; - changed = true; - } - if (refreshed.access && refreshed.access !== account.accessToken) { - account.accessToken = refreshed.access; - changed = true; - } - if ( - typeof refreshed.expires === "number" && - refreshed.expires !== account.expiresAt - ) { - account.expiresAt = refreshed.expires; - changed = true; - } - if (refreshed.refresh && refreshed.refresh !== account.refreshToken) { - account.refreshToken = refreshed.refresh; - changed = true; - } - } catch { - logWarn(`[${PLUGIN_NAME}] Failed to hydrate email for account`); - } - }), - ); - - if (changed) { - storage.accounts = accountsCopy; - await saveAccounts(storage); - } - return storage; - }; + ): Promise => + hydrateRuntimeEmails(storage, { + queuedRefresh, + extractAccountId, + sanitizeEmail, + extractAccountEmail, + shouldUpdateAccountIdFromToken, + saveAccounts, + logWarn, + pluginName: PLUGIN_NAME, + }); const applyUiRuntimeFromConfig = ( pluginConfig: ReturnType, diff --git a/lib/runtime/hydrate-emails.ts b/lib/runtime/hydrate-emails.ts new file mode 100644 index 00000000..40251fbd --- /dev/null +++ b/lib/runtime/hydrate-emails.ts @@ -0,0 +1,92 @@ +import type { AccountStorageV3 } from "../storage.js"; +import type { AccountIdSource, TokenResult } from "../types.js"; + +export async function hydrateRuntimeEmails( + storage: AccountStorageV3 | null, + deps: { + queuedRefresh: (refreshToken: string) => Promise; + extractAccountId: (accessToken: string | undefined) => string | undefined; + sanitizeEmail: (email: string | undefined) => string | undefined; + extractAccountEmail: ( + accessToken: string | undefined, + idToken?: string | undefined, + ) => string | undefined; + shouldUpdateAccountIdFromToken: ( + accountIdSource: AccountIdSource | undefined, + accountId: string | undefined, + ) => boolean; + saveAccounts: (storage: AccountStorageV3) => Promise; + logWarn: (message: string) => void; + pluginName: string; + }, +): Promise { + if (!storage) return storage; + const skipHydrate = + process.env.VITEST_WORKER_ID !== undefined || + process.env.NODE_ENV === "test" || + process.env.CODEX_SKIP_EMAIL_HYDRATE === "1"; + if (skipHydrate) return storage; + + const accountsCopy = storage.accounts.map((account) => + account ? { ...account } : account, + ); + const accountsToHydrate = accountsCopy.filter( + (account) => account && !account.email, + ); + if (accountsToHydrate.length === 0) return storage; + + let changed = false; + await Promise.all( + accountsToHydrate.map(async (account) => { + try { + const refreshed = await deps.queuedRefresh(account.refreshToken); + if (refreshed.type !== "success") return; + const id = deps.extractAccountId(refreshed.access); + const email = deps.sanitizeEmail( + deps.extractAccountEmail(refreshed.access, refreshed.idToken), + ); + if ( + id && + id !== account.accountId && + deps.shouldUpdateAccountIdFromToken( + account.accountIdSource, + account.accountId, + ) + ) { + account.accountId = id; + account.accountIdSource = "token"; + changed = true; + } + if (email && email !== account.email) { + account.email = email; + changed = true; + } + if (refreshed.access && refreshed.access !== account.accessToken) { + account.accessToken = refreshed.access; + changed = true; + } + if ( + typeof refreshed.expires === "number" && + refreshed.expires !== account.expiresAt + ) { + account.expiresAt = refreshed.expires; + changed = true; + } + if (refreshed.refresh && refreshed.refresh !== account.refreshToken) { + account.refreshToken = refreshed.refresh; + changed = true; + } + } catch { + deps.logWarn( + `[${deps.pluginName}] Failed to hydrate email for account`, + ); + } + }), + ); + + if (changed) { + storage.accounts = accountsCopy; + await deps.saveAccounts(storage); + } + return storage; +} From 7eb6263d8ab6158197b396dacbedd459c8ad3ef8 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 16:27:45 +0800 Subject: [PATCH 111/376] refactor: extract runtime account health helpers --- index.ts | 52 ++++++----------------------- lib/runtime/account-health-check.ts | 43 ++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 42 deletions(-) create mode 100644 lib/runtime/account-health-check.ts diff --git a/index.ts b/index.ts index 0f481481..be446441 100644 --- a/index.ts +++ b/index.ts @@ -164,6 +164,10 @@ import { type AccountCheckWorkingState, createAccountCheckWorkingState, } from "./lib/runtime/account-check-types.js"; +import { + clampRuntimeActiveIndices, + isRuntimeFlaggableFailure, +} from "./lib/runtime/account-health-check.js"; import { invalidateRuntimeAccountManagerCache, reloadRuntimeAccountManager, @@ -2339,45 +2343,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { let startFresh = explicitLoginMode === "fresh"; let refreshAccountIndex: number | undefined; - const clampActiveIndices = (storage: AccountStorageV3): void => { - const count = storage.accounts.length; - if (count === 0) { - storage.activeIndex = 0; - storage.activeIndexByFamily = {}; - return; - } - storage.activeIndex = Math.max( - 0, - Math.min(storage.activeIndex, count - 1), - ); - storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; - for (const family of MODEL_FAMILIES) { - const raw = storage.activeIndexByFamily[family]; - const candidate = - typeof raw === "number" && Number.isFinite(raw) - ? raw - : storage.activeIndex; - storage.activeIndexByFamily[family] = Math.max( - 0, - Math.min(candidate, count - 1), - ); - } - }; - - const isFlaggableFailure = ( - failure: Extract, - ): boolean => { - if (failure.reason === "missing_refresh") return true; - if (failure.statusCode === 401) return true; - if (failure.statusCode !== 400) return false; - const message = (failure.message ?? "").toLowerCase(); - return ( - message.includes("invalid_grant") || - message.includes("invalid refresh") || - message.includes("token has been revoked") - ); - }; - const fetchCodexQuotaSnapshot = async (params: { accountId: string; accessToken: string; @@ -2542,7 +2507,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { console.log( `[${i + 1}/${total}] ${label}: ERROR (${message})`, ); - if (deepProbe && isFlaggableFailure(refreshResult)) { + if ( + deepProbe && + isRuntimeFlaggableFailure(refreshResult) + ) { const existingIndex = state.flaggedStorage.accounts.findIndex( (flagged) => @@ -2669,7 +2637,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { (account) => !state.removeFromActive.has(account.refreshToken), ); - clampActiveIndices(workingStorage); + clampRuntimeActiveIndices(workingStorage, MODEL_FAMILIES); state.storageChanged = true; } @@ -2909,7 +2877,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { menuResult.deleteAccountIndex, 1, ); - clampActiveIndices(workingStorage); + clampRuntimeActiveIndices(workingStorage, MODEL_FAMILIES); await saveAccounts(workingStorage); await saveFlaggedAccounts({ version: 1, diff --git a/lib/runtime/account-health-check.ts b/lib/runtime/account-health-check.ts new file mode 100644 index 00000000..0a8baf09 --- /dev/null +++ b/lib/runtime/account-health-check.ts @@ -0,0 +1,43 @@ +import type { ModelFamily } from "../prompts/codex.js"; +import type { AccountStorageV3 } from "../storage.js"; +import type { TokenResult } from "../types.js"; + +export function clampRuntimeActiveIndices( + storage: AccountStorageV3, + modelFamilies: readonly ModelFamily[], +): void { + const count = storage.accounts.length; + if (count === 0) { + storage.activeIndex = 0; + storage.activeIndexByFamily = {}; + return; + } + + storage.activeIndex = Math.max(0, Math.min(storage.activeIndex, count - 1)); + storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; + for (const family of modelFamilies) { + const raw = storage.activeIndexByFamily[family]; + const candidate = + typeof raw === "number" && Number.isFinite(raw) + ? raw + : storage.activeIndex; + storage.activeIndexByFamily[family] = Math.max( + 0, + Math.min(candidate, count - 1), + ); + } +} + +export function isRuntimeFlaggableFailure( + failure: Extract, +): boolean { + if (failure.reason === "missing_refresh") return true; + if (failure.statusCode === 401) return true; + if (failure.statusCode !== 400) return false; + const message = (failure.message ?? "").toLowerCase(); + return ( + message.includes("invalid_grant") || + message.includes("invalid refresh") || + message.includes("token has been revoked") + ); +} From 3ae74bd4b16a022f4f52b5c4c922a85e84c68580 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 16:31:14 +0800 Subject: [PATCH 112/376] refactor: extract runtime login menu accounts --- index.ts | 41 ++++--------------- lib/runtime/login-menu-accounts.ts | 64 ++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 33 deletions(-) create mode 100644 lib/runtime/login-menu-accounts.ts diff --git a/index.ts b/index.ts index be446441..43424348 100644 --- a/index.ts +++ b/index.ts @@ -189,6 +189,7 @@ import { createRuntimeEventHandler } from "./lib/runtime/event-handler.js"; import { createFlaggedVerificationState } from "./lib/runtime/flagged-verify-types.js"; import { hydrateRuntimeEmails } from "./lib/runtime/hydrate-emails.js"; import { ensureRuntimeLiveAccountSync } from "./lib/runtime/live-sync.js"; +import { buildLoginMenuAccounts } from "./lib/runtime/login-menu-accounts.js"; import { buildManualOAuthFlow } from "./lib/runtime/manual-oauth-flow.js"; import { createRuntimeMetrics, @@ -2803,39 +2804,13 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const now = Date.now(); const activeIndex = resolveActiveIndex(workingStorage, "codex"); - const existingAccounts = workingStorage.accounts.map( - (account, index) => { - let status: - | "active" - | "ok" - | "rate-limited" - | "cooldown" - | "disabled"; - if (account.enabled === false) { - status = "disabled"; - } else if ( - typeof account.coolingDownUntil === "number" && - account.coolingDownUntil > now - ) { - status = "cooldown"; - } else if (formatRateLimitEntry(account, now)) { - status = "rate-limited"; - } else if (index === activeIndex) { - status = "active"; - } else { - status = "ok"; - } - return { - accountId: account.accountId, - accountLabel: account.accountLabel, - email: account.email, - index, - addedAt: account.addedAt, - lastUsed: account.lastUsed, - status, - isCurrentAccount: index === activeIndex, - enabled: account.enabled !== false, - }; + const existingAccounts = buildLoginMenuAccounts( + workingStorage.accounts, + { + now, + activeIndex, + formatRateLimitEntry: (account, currentNow) => + formatRateLimitEntry(account, currentNow), }, ); diff --git a/lib/runtime/login-menu-accounts.ts b/lib/runtime/login-menu-accounts.ts new file mode 100644 index 00000000..07ba9133 --- /dev/null +++ b/lib/runtime/login-menu-accounts.ts @@ -0,0 +1,64 @@ +type LoginMenuAccount = { + accountId?: string; + accountLabel?: string; + email?: string; + index: number; + addedAt?: number; + lastUsed?: number; + status: "active" | "ok" | "rate-limited" | "cooldown" | "disabled"; + isCurrentAccount: boolean; + enabled: boolean; +}; + +export function buildLoginMenuAccounts( + accounts: Array<{ + accountId?: string; + accountLabel?: string; + email?: string; + addedAt?: number; + lastUsed?: number; + enabled?: boolean; + coolingDownUntil?: number; + rateLimitResetTimes?: Record; + }>, + deps: { + now: number; + activeIndex: number; + formatRateLimitEntry: ( + account: { + rateLimitResetTimes?: Record; + }, + now: number, + ) => string | null; + }, +): LoginMenuAccount[] { + return accounts.map((account, index) => { + let status: LoginMenuAccount["status"]; + if (account.enabled === false) { + status = "disabled"; + } else if ( + typeof account.coolingDownUntil === "number" && + account.coolingDownUntil > deps.now + ) { + status = "cooldown"; + } else if (deps.formatRateLimitEntry(account, deps.now)) { + status = "rate-limited"; + } else if (index === deps.activeIndex) { + status = "active"; + } else { + status = "ok"; + } + + return { + accountId: account.accountId, + accountLabel: account.accountLabel, + email: account.email, + index, + addedAt: account.addedAt, + lastUsed: account.lastUsed, + status, + isCurrentAccount: index === deps.activeIndex, + enabled: account.enabled !== false, + }; + }); +} From b7293d09550499ff4bd51834222b52da83797719 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 16:36:31 +0800 Subject: [PATCH 113/376] refactor: extract runtime flagged verification helper --- index.ts | 120 +++----------------------- lib/runtime/verify-flagged.ts | 154 ++++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 109 deletions(-) create mode 100644 lib/runtime/verify-flagged.ts diff --git a/index.ts b/index.ts index 43424348..667fc6d8 100644 --- a/index.ts +++ b/index.ts @@ -186,7 +186,6 @@ import { } from "./lib/runtime/account-state.js"; import { buildCapabilityBoostByAccount } from "./lib/runtime/capability-boost.js"; import { createRuntimeEventHandler } from "./lib/runtime/event-handler.js"; -import { createFlaggedVerificationState } from "./lib/runtime/flagged-verify-types.js"; import { hydrateRuntimeEmails } from "./lib/runtime/hydrate-emails.js"; import { ensureRuntimeLiveAccountSync } from "./lib/runtime/live-sync.js"; import { buildLoginMenuAccounts } from "./lib/runtime/login-menu-accounts.js"; @@ -219,6 +218,7 @@ import { applyRuntimeUiOptions, resolveRuntimeUiOptions, } from "./lib/runtime/ui-runtime.js"; +import { verifyRuntimeFlaggedAccounts } from "./lib/runtime/verify-flagged.js"; import { SessionAffinityStore } from "./lib/session-affinity.js"; import { registerCleanup } from "./lib/shutdown.js"; import { @@ -2663,115 +2663,17 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }; const verifyFlaggedAccounts = async (): Promise => { - const flaggedStorage = await loadFlaggedAccounts(); - if (flaggedStorage.accounts.length === 0) { - console.log("\nNo flagged accounts to verify.\n"); - return; - } - - console.log("\nVerifying flagged accounts...\n"); - const state = createFlaggedVerificationState(); - - for (let i = 0; i < flaggedStorage.accounts.length; i += 1) { - const flagged = flaggedStorage.accounts[i]; - if (!flagged) continue; - const label = - flagged.email ?? flagged.accountLabel ?? `Flagged ${i + 1}`; - try { - const cached = await lookupCodexCliTokensByEmail( - flagged.email, - ); - const now = Date.now(); - if ( - cached && - typeof cached.expiresAt === "number" && - Number.isFinite(cached.expiresAt) && - cached.expiresAt > now - ) { - const refreshToken = - typeof cached.refreshToken === "string" && - cached.refreshToken.trim() - ? cached.refreshToken.trim() - : flagged.refreshToken; - const resolved = resolveAccountSelection( - { - type: "success", - access: cached.accessToken, - refresh: refreshToken, - expires: cached.expiresAt, - multiAccount: true, - }, - { logInfo }, - ); - if (!resolved.accountIdOverride && flagged.accountId) { - resolved.accountIdOverride = flagged.accountId; - resolved.accountIdSource = - flagged.accountIdSource ?? "manual"; - } - if (!resolved.accountLabel && flagged.accountLabel) { - resolved.accountLabel = flagged.accountLabel; - } - state.restored.push(resolved); - console.log( - `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: RESTORED (Codex CLI cache)`, - ); - continue; - } - - const refreshResult = await queuedRefresh( - flagged.refreshToken, - ); - if (refreshResult.type !== "success") { - console.log( - `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: STILL FLAGGED (${refreshResult.message ?? refreshResult.reason ?? "refresh failed"})`, - ); - state.remaining.push(flagged); - continue; - } - - const resolved = resolveAccountSelection(refreshResult, { - logInfo, - }); - if (!resolved.accountIdOverride && flagged.accountId) { - resolved.accountIdOverride = flagged.accountId; - resolved.accountIdSource = - flagged.accountIdSource ?? "manual"; - } - if (!resolved.accountLabel && flagged.accountLabel) { - resolved.accountLabel = flagged.accountLabel; - } - state.restored.push(resolved); - console.log( - `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: RESTORED`, - ); - } catch (error) { - const message = - error instanceof Error ? error.message : String(error); - console.log( - `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: ERROR (${message.slice(0, 120)})`, - ); - state.remaining.push({ - ...flagged, - lastError: message, - }); - } - } - - if (state.restored.length > 0) { - await persistAccounts(state.restored, false); - invalidateAccountManagerCache(); - } - - await saveFlaggedAccounts({ - version: 1, - accounts: state.remaining, + await verifyRuntimeFlaggedAccounts({ + loadFlaggedAccounts, + lookupCodexCliTokensByEmail, + queuedRefresh, + resolveAccountSelection, + persistAccounts, + invalidateAccountManagerCache, + saveFlaggedAccounts, + logInfo, + showLine: (message) => console.log(message), }); - - console.log(""); - console.log( - `Results: ${state.restored.length} restored, ${state.remaining.length} still flagged`, - ); - console.log(""); }; if (!explicitLoginMode) { diff --git a/lib/runtime/verify-flagged.ts b/lib/runtime/verify-flagged.ts new file mode 100644 index 00000000..efd3dec5 --- /dev/null +++ b/lib/runtime/verify-flagged.ts @@ -0,0 +1,154 @@ +import type { FlaggedAccountMetadataV1 } from "../storage.js"; +import type { TokenSuccessWithAccount } from "./account-selection.js"; +import { createFlaggedVerificationState } from "./flagged-verify-types.js"; + +export async function verifyRuntimeFlaggedAccounts(deps: { + loadFlaggedAccounts: () => Promise<{ + version: 1; + accounts: FlaggedAccountMetadataV1[]; + }>; + lookupCodexCliTokensByEmail: (email: string | undefined) => Promise< + | { + accessToken: string; + refreshToken?: string; + expiresAt?: number; + } + | null + | undefined + >; + queuedRefresh: (refreshToken: string) => Promise< + | { + type: "success"; + access: string; + refresh: string; + expires: number; + idToken?: string; + } + | { type: "failed"; message?: string; reason?: string } + >; + resolveAccountSelection: ( + tokens: { + type: "success"; + access: string; + refresh: string; + expires: number; + idToken?: string; + multiAccount?: boolean; + }, + context: { logInfo: (message: string) => void }, + ) => TokenSuccessWithAccount; + persistAccounts: ( + results: TokenSuccessWithAccount[], + replaceAll?: boolean, + ) => Promise; + invalidateAccountManagerCache: () => void; + saveFlaggedAccounts: (storage: { + version: 1; + accounts: FlaggedAccountMetadataV1[]; + }) => Promise; + logInfo: (message: string) => void; + logError?: (message: string) => void; + showLine: (message: string) => void; + now?: () => number; +}): Promise { + const flaggedStorage = await deps.loadFlaggedAccounts(); + if (flaggedStorage.accounts.length === 0) { + deps.showLine("\nNo flagged accounts to verify.\n"); + return; + } + + deps.showLine("\nVerifying flagged accounts...\n"); + const state = createFlaggedVerificationState(); + + for (let i = 0; i < flaggedStorage.accounts.length; i += 1) { + const flagged = flaggedStorage.accounts[i]; + if (!flagged) continue; + const label = flagged.email ?? flagged.accountLabel ?? `Flagged ${i + 1}`; + try { + const cached = await deps.lookupCodexCliTokensByEmail(flagged.email); + const now = deps.now?.() ?? Date.now(); + if ( + cached && + typeof cached.expiresAt === "number" && + Number.isFinite(cached.expiresAt) && + cached.expiresAt > now + ) { + const refreshToken = + typeof cached.refreshToken === "string" && cached.refreshToken.trim() + ? cached.refreshToken.trim() + : flagged.refreshToken; + const resolved = deps.resolveAccountSelection( + { + type: "success", + access: cached.accessToken, + refresh: refreshToken, + expires: cached.expiresAt, + multiAccount: true, + }, + { logInfo: deps.logInfo }, + ); + if (!resolved.accountIdOverride && flagged.accountId) { + resolved.accountIdOverride = flagged.accountId; + resolved.accountIdSource = flagged.accountIdSource ?? "manual"; + } + if (!resolved.accountLabel && flagged.accountLabel) { + resolved.accountLabel = flagged.accountLabel; + } + state.restored.push(resolved); + deps.showLine( + `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: RESTORED (Codex CLI cache)`, + ); + continue; + } + + const refreshResult = await deps.queuedRefresh(flagged.refreshToken); + if (refreshResult.type !== "success") { + deps.showLine( + `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: STILL FLAGGED (${refreshResult.message ?? refreshResult.reason ?? "refresh failed"})`, + ); + state.remaining.push(flagged); + continue; + } + + const resolved = deps.resolveAccountSelection(refreshResult, { + logInfo: deps.logInfo, + }); + if (!resolved.accountIdOverride && flagged.accountId) { + resolved.accountIdOverride = flagged.accountId; + resolved.accountIdSource = flagged.accountIdSource ?? "manual"; + } + if (!resolved.accountLabel && flagged.accountLabel) { + resolved.accountLabel = flagged.accountLabel; + } + state.restored.push(resolved); + deps.showLine( + `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: RESTORED`, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + deps.showLine( + `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: ERROR (${message.slice(0, 120)})`, + ); + state.remaining.push({ + ...flagged, + lastError: message, + }); + } + } + + if (state.restored.length > 0) { + await deps.persistAccounts(state.restored, false); + deps.invalidateAccountManagerCache(); + } + + await deps.saveFlaggedAccounts({ + version: 1, + accounts: state.remaining, + }); + + deps.showLine(""); + deps.showLine( + `Results: ${state.restored.length} restored, ${state.remaining.length} still flagged`, + ); + deps.showLine(""); +} From ad39969acd40a57ba5166d9b4f73a39e1e9ae38c Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 16:38:22 +0800 Subject: [PATCH 114/376] fix(runtime): restore live sync publication order --- index.ts | 5 +++ lib/runtime/live-sync.ts | 17 ++++++++- test/runtime-live-sync.test.ts | 68 +++++++++++++++++++++++++++++++++- 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/index.ts b/index.ts index 0cdc2d05..6bbc5fa6 100644 --- a/index.ts +++ b/index.ts @@ -475,6 +475,11 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { reloadAccountManagerFromDisk, getLiveAccountSyncDebounceMs, getLiveAccountSyncPollMs, + commitState: ({ sync, path, cleanupRegistered }) => { + liveAccountSync = sync; + liveAccountSyncPath = path; + liveAccountSyncCleanupRegistered = cleanupRegistered; + }, registerCleanup, logWarn, pluginName: PLUGIN_NAME, diff --git a/lib/runtime/live-sync.ts b/lib/runtime/live-sync.ts index a21da320..7d4ef24a 100644 --- a/lib/runtime/live-sync.ts +++ b/lib/runtime/live-sync.ts @@ -26,6 +26,11 @@ export async function ensureRuntimeLiveAccountSync< ) => Promise; getLiveAccountSyncDebounceMs: (config: TConfig) => number; getLiveAccountSyncPollMs: (config: TConfig) => number; + commitState: (state: { + sync: TSync | null; + path: string | null; + cleanupRegistered: boolean; + }) => void; registerCleanup: (cleanup: () => void) => void; logWarn: (message: string) => void; pluginName: string; @@ -46,6 +51,14 @@ export async function ensureRuntimeLiveAccountSync< const targetPath = deps.getStoragePath(); let sync = deps.currentSync; let cleanupRegistered = deps.currentCleanupRegistered; + let nextPath = deps.currentPath; + const commitState = (): void => { + deps.commitState({ + sync, + path: nextPath, + cleanupRegistered, + }); + }; if (!sync) { sync = deps.createSync( async () => { @@ -56,21 +69,23 @@ export async function ensureRuntimeLiveAccountSync< pollIntervalMs: deps.getLiveAccountSyncPollMs(deps.pluginConfig), }, ); + commitState(); if (!cleanupRegistered) { deps.registerCleanup(() => { deps.getCurrentSync()?.stop(); }); cleanupRegistered = true; + commitState(); } } - let nextPath = deps.currentPath; if (nextPath !== targetPath) { let switched = false; for (let attempt = 0; attempt < 3; attempt += 1) { try { await sync.syncToPath(targetPath); nextPath = targetPath; + commitState(); switched = true; break; } catch (error) { diff --git a/test/runtime-live-sync.test.ts b/test/runtime-live-sync.test.ts index 096862d0..9988b969 100644 --- a/test/runtime-live-sync.test.ts +++ b/test/runtime-live-sync.test.ts @@ -18,7 +18,15 @@ describe("runtime live sync", () => { pluginName?: string; } = {}) { let liveSync = overrides.currentSync ?? null; - const registerCleanup = vi.fn(); + let committedState = { + sync: overrides.currentSync ?? null, + path: overrides.currentPath ?? null, + cleanupRegistered: overrides.currentCleanupRegistered ?? false, + }; + let cleanupCallback: (() => void) | null = null; + const registerCleanup = vi.fn((callback: () => void) => { + cleanupCallback = callback; + }); const createSync = vi.fn((_onChange, _options) => ({ stop: vi.fn(), syncToPath: vi.fn().mockResolvedValue(undefined), @@ -40,6 +48,10 @@ describe("runtime live sync", () => { reloadAccountManagerFromDisk: vi.fn().mockResolvedValue(undefined), getLiveAccountSyncDebounceMs: vi.fn().mockReturnValue(25), getLiveAccountSyncPollMs: vi.fn().mockReturnValue(250), + commitState: vi.fn((state) => { + committedState = state; + liveSync = state.sync; + }), registerCleanup, logWarn: vi.fn(), pluginName: overrides.pluginName ?? "codex-multi-auth", @@ -49,6 +61,12 @@ describe("runtime live sync", () => { deps, createSync, registerCleanup, + getCleanupCallback() { + return cleanupCallback; + }, + getCommittedState() { + return committedState; + }, setLiveSync(value: typeof liveSync) { liveSync = value; }, @@ -177,8 +195,49 @@ describe("runtime live sync", () => { expect(deps.logWarn).not.toHaveBeenCalled(); }); + it("commits a newly created sync before awaiting the initial path switch", async () => { + let resolveSwitch: (() => void) | null = null; + const switchPromise = new Promise((resolve) => { + resolveSwitch = resolve; + }); + const createdSync = { + stop: vi.fn(), + syncToPath: vi.fn().mockImplementation(() => switchPromise), + }; + const { deps, createSync, getCommittedState } = createDeps(); + createSync.mockReturnValue(createdSync); + + const pending = ensureRuntimeLiveAccountSync(deps); + await vi.runAllTicks(); + + const committed = getCommittedState(); + expect(committed.sync).toBe(createdSync); + expect(committed.cleanupRegistered).toBe(true); + + const second = ensureRuntimeLiveAccountSync({ + ...deps, + currentSync: committed.sync, + currentPath: committed.path, + currentCleanupRegistered: committed.cleanupRegistered, + }); + await vi.runAllTicks(); + expect(createSync).toHaveBeenCalledTimes(1); + + resolveSwitch?.(); + await expect(pending).resolves.toMatchObject({ + sync: createdSync, + path: "C:\\repo\\accounts.json", + cleanupRegistered: true, + }); + await expect(second).resolves.toMatchObject({ + sync: createdSync, + path: "C:\\repo\\accounts.json", + cleanupRegistered: true, + }); + }); + it("does not accumulate cleanup handlers when sync is toggled off and on again", async () => { - const { deps, registerCleanup, setLiveSync } = createDeps(); + const { deps, getCleanupCallback, registerCleanup, setLiveSync } = createDeps(); const first = await ensureRuntimeLiveAccountSync(deps); setLiveSync(first.sync); @@ -202,5 +261,10 @@ describe("runtime live sync", () => { setLiveSync(reenabled.sync); expect(registerCleanup).toHaveBeenCalledTimes(1); + const cleanup = getCleanupCallback(); + expect(cleanup).not.toBeNull(); + cleanup?.(); + expect((reenabled.sync as { stop: ReturnType }).stop).toHaveBeenCalledTimes(1); + expect((first.sync as { stop: ReturnType }).stop).toHaveBeenCalledTimes(1); }); }); From 4e5faaf7bdb9625d75f76d07981d4bc2b8c3cfca Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 16:38:22 +0800 Subject: [PATCH 115/376] fix(runtime): preserve manual login callback behavior --- index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/index.ts b/index.ts index f02ae594..95f62fad 100644 --- a/index.ts +++ b/index.ts @@ -3995,7 +3995,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { onSuccess: async (tokens: TokenSuccessWithAccount) => { try { await persistAccountPool([tokens], false); - invalidateAccountManagerCache(); } catch (err) { const storagePath = getStoragePath(); const errorCode = From 531dd37df84bb8b9f67401521d40b55802c59c41 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 16:39:53 +0800 Subject: [PATCH 116/376] test(runtime): cover live sync create failure path --- test/runtime-live-sync.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/runtime-live-sync.test.ts b/test/runtime-live-sync.test.ts index 9988b969..1bbe214f 100644 --- a/test/runtime-live-sync.test.ts +++ b/test/runtime-live-sync.test.ts @@ -195,6 +195,27 @@ describe("runtime live sync", () => { expect(deps.logWarn).not.toHaveBeenCalled(); }); + it("keeps a newly created sync reachable when the initial path switch fails", async () => { + const createdSync = { + stop: vi.fn(), + syncToPath: vi.fn().mockRejectedValue(new Error("boom")), + }; + const { deps, createSync, getCleanupCallback, getCommittedState } = createDeps(); + createSync.mockReturnValue(createdSync); + + await expect(ensureRuntimeLiveAccountSync(deps)).rejects.toThrow("boom"); + + const committed = getCommittedState(); + expect(committed.sync).toBe(createdSync); + expect(committed.path).toBeNull(); + expect(committed.cleanupRegistered).toBe(true); + + const cleanup = getCleanupCallback(); + expect(cleanup).not.toBeNull(); + cleanup?.(); + expect(createdSync.stop).toHaveBeenCalledTimes(1); + }); + it("commits a newly created sync before awaiting the initial path switch", async () => { let resolveSwitch: (() => void) | null = null; const switchPromise = new Promise((resolve) => { From 57cf330b45ca350774f3c871811b0da93b6ece59 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 16:42:43 +0800 Subject: [PATCH 117/376] refactor: extract runtime auth facade --- index.ts | 45 +++++++++++----------- lib/runtime/auth-facade.ts | 78 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 22 deletions(-) create mode 100644 lib/runtime/auth-facade.ts diff --git a/index.ts b/index.ts index 667fc6d8..6ebaaecb 100644 --- a/index.ts +++ b/index.ts @@ -184,6 +184,11 @@ import { getRateLimitResetTimeForFamily, resolveActiveIndex, } from "./lib/runtime/account-state.js"; +import { + createAccountManagerReloader, + createPersistAccounts, + runRuntimeOAuthFlow, +} from "./lib/runtime/auth-facade.js"; import { buildCapabilityBoostByAccount } from "./lib/runtime/capability-boost.js"; import { createRuntimeEventHandler } from "./lib/runtime/event-handler.js"; import { hydrateRuntimeEmails } from "./lib/runtime/hydrate-emails.js"; @@ -323,26 +328,24 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const runOAuthFlow = async ( forceNewLogin: boolean = false, ): Promise => - runOAuthBrowserFlow({ - forceNewLogin, + runRuntimeOAuthFlow(forceNewLogin, { + runOAuthBrowserFlow, manualModeLabel: AUTH_LABELS.OAUTH_MANUAL, logInfo, - logDebug: (message) => logDebug(`[${PLUGIN_NAME}] ${message}`), - logWarn: (message) => logWarn(`[${PLUGIN_NAME}] ${message}`), + logDebug, + logWarn, + pluginName: PLUGIN_NAME, }); - const persistAccounts = async ( - results: TokenSuccessWithAccount[], - replaceAll: boolean = false, - ): Promise => - persistAccountPool(results, replaceAll, { - withAccountStorageTransaction, - extractAccountId, - extractAccountEmail, - sanitizeEmail, - findMatchingAccountIndex, - MODEL_FAMILIES, - }); + const persistAccounts = createPersistAccounts({ + persistAccountPool, + withAccountStorageTransaction, + extractAccountId, + extractAccountEmail, + sanitizeEmail, + findMatchingAccountIndex, + MODEL_FAMILIES, + }); const showToast = async ( message: string, @@ -398,11 +401,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }); }; - const reloadAccountManagerFromDisk = async ( - authFallback?: OAuthAuthDetails, - ): Promise => - reloadRuntimeAccountManager({ - currentReloadInFlight: accountReloadInFlight, + const reloadAccountManagerFromDisk = + createAccountManagerReloader({ + reloadRuntimeAccountManager, + getReloadInFlight: () => accountReloadInFlight, loadFromDisk: (fallback) => AccountManager.loadFromDisk(fallback), setCachedAccountManager: (value) => { cachedAccountManager = value; @@ -413,7 +415,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { setReloadInFlight: (value) => { accountReloadInFlight = value; }, - authFallback, }); const applyStorageScope = ( diff --git a/lib/runtime/auth-facade.ts b/lib/runtime/auth-facade.ts new file mode 100644 index 00000000..1b9a60fd --- /dev/null +++ b/lib/runtime/auth-facade.ts @@ -0,0 +1,78 @@ +import type { OAuthAuthDetails, TokenResult } from "../types.js"; +import type { PersistAccountPoolDeps } from "./account-pool.js"; +import type { TokenSuccessWithAccount } from "./account-selection.js"; + +export async function runRuntimeOAuthFlow( + forceNewLogin: boolean, + deps: { + runOAuthBrowserFlow: (input: { + forceNewLogin: boolean; + manualModeLabel: string; + logInfo: (message: string) => void; + logDebug: (message: string) => void; + logWarn: (message: string) => void; + }) => Promise; + manualModeLabel: string; + logInfo: (message: string) => void; + logDebug: (message: string) => void; + logWarn: (message: string) => void; + pluginName: string; + }, +): Promise { + return deps.runOAuthBrowserFlow({ + forceNewLogin, + manualModeLabel: deps.manualModeLabel, + logInfo: deps.logInfo, + logDebug: (message) => deps.logDebug(`[${deps.pluginName}] ${message}`), + logWarn: (message) => deps.logWarn(`[${deps.pluginName}] ${message}`), + }); +} + +export function createPersistAccounts( + deps: { + persistAccountPool: ( + results: TokenSuccessWithAccount[], + replaceAll: boolean, + options: PersistAccountPoolDeps, + ) => Promise; + } & PersistAccountPoolDeps, +) { + return async ( + results: TokenSuccessWithAccount[], + replaceAll: boolean = false, + ): Promise => + deps.persistAccountPool(results, replaceAll, { + withAccountStorageTransaction: deps.withAccountStorageTransaction, + extractAccountId: deps.extractAccountId, + extractAccountEmail: deps.extractAccountEmail, + sanitizeEmail: deps.sanitizeEmail, + findMatchingAccountIndex: deps.findMatchingAccountIndex, + MODEL_FAMILIES: deps.MODEL_FAMILIES, + }); +} + +export function createAccountManagerReloader(deps: { + reloadRuntimeAccountManager: (input: { + currentReloadInFlight: Promise | null; + loadFromDisk: (fallback?: OAuthAuthDetails) => Promise; + setCachedAccountManager: (value: TAccountManager) => void; + setAccountManagerPromise: (value: Promise | null) => void; + setReloadInFlight: (value: Promise | null) => void; + authFallback?: OAuthAuthDetails; + }) => Promise; + getReloadInFlight: () => Promise | null; + loadFromDisk: (fallback?: OAuthAuthDetails) => Promise; + setCachedAccountManager: (value: TAccountManager) => void; + setAccountManagerPromise: (value: Promise | null) => void; + setReloadInFlight: (value: Promise | null) => void; +}) { + return async (authFallback?: OAuthAuthDetails): Promise => + deps.reloadRuntimeAccountManager({ + currentReloadInFlight: deps.getReloadInFlight(), + loadFromDisk: deps.loadFromDisk, + setCachedAccountManager: deps.setCachedAccountManager, + setAccountManagerPromise: deps.setAccountManagerPromise, + setReloadInFlight: deps.setReloadInFlight, + authFallback, + }); +} From b815919838a1703b2b778314ea34473e906eef9e Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 16:44:42 +0800 Subject: [PATCH 118/376] refactor: route runtime flagged verification helper --- index.ts | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/index.ts b/index.ts index 6ebaaecb..20b72a5d 100644 --- a/index.ts +++ b/index.ts @@ -2663,20 +2663,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { console.log(""); }; - const verifyFlaggedAccounts = async (): Promise => { - await verifyRuntimeFlaggedAccounts({ - loadFlaggedAccounts, - lookupCodexCliTokensByEmail, - queuedRefresh, - resolveAccountSelection, - persistAccounts, - invalidateAccountManagerCache, - saveFlaggedAccounts, - logInfo, - showLine: (message) => console.log(message), - }); - }; - if (!explicitLoginMode) { while (true) { const loadedStorage = await hydrateEmails(await loadAccounts()); @@ -2742,7 +2728,17 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { continue; } if (menuResult.mode === "verify-flagged") { - await verifyFlaggedAccounts(); + await verifyRuntimeFlaggedAccounts({ + loadFlaggedAccounts, + lookupCodexCliTokensByEmail, + queuedRefresh, + resolveAccountSelection, + persistAccounts, + invalidateAccountManagerCache, + saveFlaggedAccounts, + logInfo, + showLine: (message) => console.log(message), + }); continue; } From 11a86216b1d587d5e01584f6fa9d27d78825c1c5 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 16:47:31 +0800 Subject: [PATCH 119/376] refactor: add package subpath exports --- package.json | 27 ++++++++ test/public-api-contract.test.ts | 110 +++++++++++++++++++++++-------- 2 files changed, 110 insertions(+), 27 deletions(-) diff --git a/package.json b/package.json index e4bae7b8..5f77bcfe 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,33 @@ "description": "Multi-account OAuth manager and codex auth wrapper for the official @openai/codex CLI, with switching, health checks, and recovery tools", "main": "./dist/index.js", "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./auth": { + "types": "./dist/lib/auth/auth.d.ts", + "default": "./dist/lib/auth/auth.js" + }, + "./storage": { + "types": "./dist/lib/storage.d.ts", + "default": "./dist/lib/storage.js" + }, + "./config": { + "types": "./dist/lib/config.d.ts", + "default": "./dist/lib/config.js" + }, + "./request": { + "types": "./dist/lib/request/fetch-helpers.d.ts", + "default": "./dist/lib/request/fetch-helpers.js" + }, + "./cli": { + "types": "./dist/lib/codex-manager.d.ts", + "default": "./dist/lib/codex-manager.js" + }, + "./package.json": "./package.json" + }, "type": "module", "license": "MIT", "author": "ndycode", diff --git a/test/public-api-contract.test.ts b/test/public-api-contract.test.ts index 307093f3..40db0c58 100644 --- a/test/public-api-contract.test.ts +++ b/test/public-api-contract.test.ts @@ -1,10 +1,4 @@ import { describe, expect, it, vi } from "vitest"; -import { - HealthScoreTracker, - TokenBucketTracker, - exponentialBackoff, - selectHybridAccount, -} from "../lib/rotation.js"; import { getTopCandidates } from "../lib/parallel-probe.js"; import { createCodexHeaders } from "../lib/request/fetch-helpers.js"; import { @@ -12,6 +6,12 @@ import { getRateLimitBackoffWithReason, } from "../lib/request/rate-limit-backoff.js"; import { transformRequestBody } from "../lib/request/request-transformer.js"; +import { + exponentialBackoff, + HealthScoreTracker, + selectHybridAccount, + TokenBucketTracker, +} from "../lib/rotation.js"; import type { RequestBody } from "../lib/types.js"; describe("public api contract", () => { @@ -22,22 +22,50 @@ describe("public api contract", () => { expect(root.default).toBe(root.OpenAIOAuthPlugin); }); + it("publishes stable package subpath exports", async () => { + const pkg = await import("../package.json", { with: { type: "json" } }); + const exportsField = pkg.default.exports as Record; + expect(exportsField["./auth"]).toEqual({ + types: "./dist/lib/auth/auth.d.ts", + default: "./dist/lib/auth/auth.js", + }); + expect(exportsField["./storage"]).toEqual({ + types: "./dist/lib/storage.d.ts", + default: "./dist/lib/storage.js", + }); + expect(exportsField["./config"]).toEqual({ + types: "./dist/lib/config.d.ts", + default: "./dist/lib/config.js", + }); + expect(exportsField["./request"]).toEqual({ + types: "./dist/lib/request/fetch-helpers.d.ts", + default: "./dist/lib/request/fetch-helpers.js", + }); + expect(exportsField["./cli"]).toEqual({ + types: "./dist/lib/codex-manager.d.ts", + default: "./dist/lib/codex-manager.js", + }); + }); + it("keeps compatibility exports for module helpers", async () => { const rotation = await import("../lib/rotation.js"); const parallelProbe = await import("../lib/parallel-probe.js"); const fetchHelpers = await import("../lib/request/fetch-helpers.js"); - const rateLimitBackoff = await import("../lib/request/rate-limit-backoff.js"); - const requestTransformer = await import("../lib/request/request-transformer.js"); - const required: ReadonlyArray< - readonly [string, Record] - > = [ - ["selectHybridAccount", rotation], - ["exponentialBackoff", rotation], - ["getTopCandidates", parallelProbe], - ["createCodexHeaders", fetchHelpers], - ["getRateLimitBackoffWithReason", rateLimitBackoff], - ["transformRequestBody", requestTransformer], - ]; + const rateLimitBackoff = await import( + "../lib/request/rate-limit-backoff.js" + ); + const requestTransformer = await import( + "../lib/request/request-transformer.js" + ); + const required: ReadonlyArray]> = + [ + ["selectHybridAccount", rotation], + ["exponentialBackoff", rotation], + ["getTopCandidates", parallelProbe], + ["createCodexHeaders", fetchHelpers], + ["getRateLimitBackoffWithReason", rateLimitBackoff], + ["transformRequestBody", requestTransformer], + ]; for (const [name, mod] of required) { expect(name in mod, `missing export: ${name}`).toBe(true); expect(typeof mod[name], `${name} should be a function`).toBe("function"); @@ -47,16 +75,31 @@ describe("public api contract", () => { it("keeps positional and options-object overload behavior aligned", async () => { const healthTracker = new HealthScoreTracker(); const tokenTracker = new TokenBucketTracker(); - const accounts = [{ index: 0, isAvailable: true, lastUsed: 1_709_280_000_000 }]; + const accounts = [ + { index: 0, isAvailable: true, lastUsed: 1_709_280_000_000 }, + ]; - const selectedPositional = selectHybridAccount(accounts, healthTracker, tokenTracker); - const selectedNamed = selectHybridAccount({ accounts, healthTracker, tokenTracker }); + const selectedPositional = selectHybridAccount( + accounts, + healthTracker, + tokenTracker, + ); + const selectedNamed = selectHybridAccount({ + accounts, + healthTracker, + tokenTracker, + }); expect(selectedNamed?.index).toBe(selectedPositional?.index); const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5); try { const backoffPositional = exponentialBackoff(3, 1000, 60000, 0); - const backoffNamed = exponentialBackoff({ attempt: 3, baseMs: 1000, maxMs: 60000, jitterFactor: 0 }); + const backoffNamed = exponentialBackoff({ + attempt: 3, + baseMs: 1000, + maxMs: 60000, + jitterFactor: 0, + }); expect(backoffNamed).toBe(backoffPositional); } finally { randomSpy.mockRestore(); @@ -82,7 +125,9 @@ describe("public api contract", () => { 1, ); const topNamed = getTopCandidates({ - accountManager: manager as unknown as Parameters[0], + accountManager: manager as unknown as Parameters< + typeof getTopCandidates + >[0], modelFamily: "codex", model: null, maxCandidates: 1, @@ -99,11 +144,22 @@ describe("public api contract", () => { accessToken: "token", opts: { model: "gpt-5", promptCacheKey: "session-compat" }, }); - expect(headersNamed.get("Authorization")).toBe(headersPositional.get("Authorization")); - expect(headersNamed.get("conversation_id")).toBe(headersPositional.get("conversation_id")); - expect(headersNamed.get("session_id")).toBe(headersPositional.get("session_id")); + expect(headersNamed.get("Authorization")).toBe( + headersPositional.get("Authorization"), + ); + expect(headersNamed.get("conversation_id")).toBe( + headersPositional.get("conversation_id"), + ); + expect(headersNamed.get("session_id")).toBe( + headersPositional.get("session_id"), + ); - const ratePositional = getRateLimitBackoffWithReason(1, "compat", 1000, "tokens"); + const ratePositional = getRateLimitBackoffWithReason( + 1, + "compat", + 1000, + "tokens", + ); clearRateLimitBackoffState(); const rateNamed = getRateLimitBackoffWithReason({ accountIndex: 1, From 686a6d2e57a78ae87b48a00232063a08aaed9634 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 16:48:53 +0800 Subject: [PATCH 120/376] ci: add node 22 smoke job to pr ci --- .github/workflows/pr-ci.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index daccaa95..5ce9f684 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -49,3 +49,26 @@ jobs: - name: Build run: npm run build + + node22-smoke: + name: Node 22 Smoke + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.x + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run type check + run: npm run typecheck + + - name: Build + run: npm run build From 17b1f690d4ec27262529b743a4d2485ae0aff8d4 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 16:53:58 +0800 Subject: [PATCH 121/376] ci: add pack budget check --- .github/workflows/pr-ci.yml | 3 ++ package.json | 1 + scripts/check-pack-budget.mjs | 82 +++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+) create mode 100644 scripts/check-pack-budget.mjs diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 5ce9f684..56ec2d94 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -50,6 +50,9 @@ jobs: - name: Build run: npm run build + - name: Pack budget check + run: npm run pack:check + node22-smoke: name: Node 22 Smoke runs-on: ubuntu-latest diff --git a/package.json b/package.json index 5f77bcfe..edb6d052 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "test:model-matrix:report": "node scripts/test-model-matrix.js --smoke --report-json=.tmp/model-matrix-report.json", "clean:repo": "node scripts/repo-hygiene.js clean --mode aggressive", "clean:repo:check": "node scripts/repo-hygiene.js check", + "pack:check": "node scripts/check-pack-budget.mjs", "bench:edit-formats": "node scripts/benchmark-edit-formats.mjs --preset=codex-core", "bench:edit-formats:smoke": "node scripts/benchmark-edit-formats.mjs --smoke --preset=codex-core", "bench:edit-formats:render": "node scripts/benchmark-render-dashboard.mjs", diff --git a/scripts/check-pack-budget.mjs b/scripts/check-pack-budget.mjs new file mode 100644 index 00000000..3659724e --- /dev/null +++ b/scripts/check-pack-budget.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +import { exec } from "node:child_process"; +import { promisify } from "node:util"; + +const execAsync = promisify(exec); + +const MAX_PACKAGE_SIZE = 8 * 1024 * 1024; +const REQUIRED_PREFIXES = [ + "dist/", + "assets/", + "config/", + "scripts/", + "vendor/codex-ai-plugin/", + "vendor/codex-ai-sdk/", + "README.md", + "LICENSE", +]; + +const FORBIDDEN_PREFIXES = [ + ".github/", + "test/", + "src/", + "tmp", + ".tmp/", + ".codex/", +]; + +const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; + +const { stdout } = await execAsync(`${npmCommand} pack --dry-run --json`, { + windowsHide: true, + maxBuffer: 10 * 1024 * 1024, +}); + +const packs = JSON.parse(stdout); +if (!Array.isArray(packs) || packs.length === 0) { + throw new Error("npm pack --dry-run --json returned no package metadata"); +} + +const pack = packs[0]; +if (!pack || !Array.isArray(pack.files)) { + throw new Error("npm pack metadata did not include file list"); +} + +const packageSize = typeof pack.size === "number" ? pack.size : 0; +if (packageSize <= 0) { + throw new Error("npm pack metadata did not include a valid package size"); +} + +if (packageSize > MAX_PACKAGE_SIZE) { + throw new Error( + `Packed tarball is too large: ${packageSize} bytes (max ${MAX_PACKAGE_SIZE})`, + ); +} + +const paths = pack.files + .map((file) => file.path) + .filter((value) => typeof value === "string"); + +for (const forbidden of FORBIDDEN_PREFIXES) { + const leaked = paths.find( + (path) => path === forbidden || path.startsWith(forbidden), + ); + if (leaked) { + throw new Error(`Forbidden file leaked into package: ${leaked}`); + } +} + +for (const required of REQUIRED_PREFIXES) { + const present = paths.some( + (path) => path === required || path.startsWith(required), + ); + if (!present) { + throw new Error( + `Required package content missing from npm pack output: ${required}`, + ); + } +} + +console.log( + `Pack budget ok: ${packageSize} bytes across ${paths.length} files`, +); From 7aa51dffdedcaa668dfb1ce0f7d032a60529c097 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 17:06:16 +0800 Subject: [PATCH 122/376] ci: verify vendor provenance --- .github/workflows/pr-ci.yml | 3 ++ package.json | 1 + scripts/verify-vendor-provenance.mjs | 39 ++++++++++++++++++++ vendor/provenance.json | 53 ++++++++++++++++++++++++++++ 4 files changed, 96 insertions(+) create mode 100644 scripts/verify-vendor-provenance.mjs create mode 100644 vendor/provenance.json diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 56ec2d94..591fcf30 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -53,6 +53,9 @@ jobs: - name: Pack budget check run: npm run pack:check + - name: Verify vendor provenance + run: npm run vendor:verify + node22-smoke: name: Node 22 Smoke runs-on: ubuntu-latest diff --git a/package.json b/package.json index edb6d052..35e1fcd8 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "audit:all": "npm audit --audit-level=high", "audit:dev:allowlist": "node scripts/audit-dev-allowlist.js", "audit:ci": "npm run audit:prod && npm run audit:dev:allowlist", + "vendor:verify": "node scripts/verify-vendor-provenance.mjs", "prepublishOnly": "npm run build", "prepare": "husky" }, diff --git a/scripts/verify-vendor-provenance.mjs b/scripts/verify-vendor-provenance.mjs new file mode 100644 index 00000000..ccf075c7 --- /dev/null +++ b/scripts/verify-vendor-provenance.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; + +const manifest = JSON.parse( + await readFile(new URL("../vendor/provenance.json", import.meta.url), "utf8"), +); + +if (!manifest || !Array.isArray(manifest.components)) { + throw new Error("vendor/provenance.json is missing a valid components array"); +} + +for (const component of manifest.components) { + if ( + !component || + !Array.isArray(component.files) || + component.files.length === 0 + ) { + throw new Error( + `Component provenance entry is invalid: ${JSON.stringify(component)}`, + ); + } + for (const file of component.files) { + if (!file?.path || !file?.sha256) { + throw new Error(`Invalid file provenance entry in ${component.name}`); + } + const content = await readFile(file.path); + const actual = createHash("sha256").update(content).digest("hex"); + if (actual !== file.sha256) { + throw new Error( + `Vendor provenance mismatch for ${file.path}: expected ${file.sha256}, got ${actual}`, + ); + } + } +} + +console.log( + `Vendor provenance ok: ${manifest.components.length} component(s), ${manifest.components.reduce((sum, component) => sum + component.files.length, 0)} file(s) verified`, +); diff --git a/vendor/provenance.json b/vendor/provenance.json new file mode 100644 index 00000000..0cbb8ca6 --- /dev/null +++ b/vendor/provenance.json @@ -0,0 +1,53 @@ +{ + "generatedAt": "2026-03-21", + "components": [ + { + "name": "@codex-ai/plugin", + "version": "1.2.10-codex.1", + "source": "vendored dist shim", + "root": "vendor/codex-ai-plugin", + "files": [ + { + "path": "vendor/codex-ai-plugin/package.json", + "sha256": "b4dda9e535af9546e647f7651e1da57db1dae8af40d14eb4d97bb0ba972b8bc8" + }, + { + "path": "vendor/codex-ai-plugin/dist/index.js", + "sha256": "aee9a2454b1778ff0af7c648a172074fe61446e21e76bac0e354ef1d12c0dc22" + }, + { + "path": "vendor/codex-ai-plugin/dist/index.d.ts", + "sha256": "9cd22362749c262f7f87e05ea981659a14f0e03450aeb6ab4e7ad39693662c10" + }, + { + "path": "vendor/codex-ai-plugin/dist/tool.js", + "sha256": "79b5301d1a200b5614d1b38420b62b6080ce77999be2bc25fc1c5239abe9f197" + }, + { + "path": "vendor/codex-ai-plugin/dist/tool.d.ts", + "sha256": "c04a703b191beeadb97293d059a93bad4fd445afacc4849fcf25346b1456f160" + } + ] + }, + { + "name": "@codex-ai/sdk", + "version": "1.2.10-codex.1", + "source": "vendored dist shim", + "root": "vendor/codex-ai-sdk", + "files": [ + { + "path": "vendor/codex-ai-sdk/package.json", + "sha256": "8addd64386f878ba30f3ab12e2b86bdcd1d8eeb24cb2ad692be7de73587211a4" + }, + { + "path": "vendor/codex-ai-sdk/dist/index.js", + "sha256": "aee9a2454b1778ff0af7c648a172074fe61446e21e76bac0e354ef1d12c0dc22" + }, + { + "path": "vendor/codex-ai-sdk/dist/index.d.ts", + "sha256": "41bffc0c5a2d44f83ab4d8bd3618c35bd0cd0bf19534185943e26d23a0e41b23" + } + ] + } + ] +} From c645d325405ae191c4d5acd6159487ad2072f328 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 17:07:23 +0800 Subject: [PATCH 123/376] fix(runtime): return false for skipped account events --- lib/runtime/account-select-event.ts | 4 +-- test/runtime-request-init.test.ts | 46 +++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/lib/runtime/account-select-event.ts b/lib/runtime/account-select-event.ts index 9ee2c2e4..be80062c 100644 --- a/lib/runtime/account-select-event.ts +++ b/lib/runtime/account-select-event.ts @@ -39,11 +39,11 @@ export async function handleAccountSelectEvent(input: { } const index = props.index ?? props.accountIndex; - if (typeof index !== "number") return true; + if (typeof index !== "number") return false; const storage = await input.loadAccounts(); if (!storage || index < 0 || index >= storage.accounts.length) { - return true; + return false; } const now = Date.now(); diff --git a/test/runtime-request-init.test.ts b/test/runtime-request-init.test.ts index 0d729107..aa5f435e 100644 --- a/test/runtime-request-init.test.ts +++ b/test/runtime-request-init.test.ts @@ -195,6 +195,52 @@ describe("runtime account select event helper", () => { expect(loadAccounts).not.toHaveBeenCalled(); }); + it("returns false when the event is missing an account index", async () => { + const loadAccounts = vi.fn(); + + await expect( + handleAccountSelectEvent({ + event: { + type: "account.select", + properties: { provider: "openai" }, + }, + providerId: "openai", + loadAccounts, + saveAccounts: vi.fn(), + modelFamilies: [], + cachedAccountManager: null, + reloadAccountManagerFromDisk: vi.fn(), + setLastCodexCliActiveSyncIndex: vi.fn(), + showToast: vi.fn(), + }), + ).resolves.toBe(false); + expect(loadAccounts).not.toHaveBeenCalled(); + }); + + it("returns false when the requested account index is unavailable", async () => { + const saveAccounts = vi.fn(); + const showToast = vi.fn(); + + await expect( + handleAccountSelectEvent({ + event: { + type: "account.select", + properties: { index: 3, provider: "openai" }, + }, + providerId: "openai", + loadAccounts: vi.fn().mockResolvedValue(structuredClone(storage)), + saveAccounts, + modelFamilies: ["gpt-5"], + cachedAccountManager: null, + reloadAccountManagerFromDisk: vi.fn(), + setLastCodexCliActiveSyncIndex: vi.fn(), + showToast, + }), + ).resolves.toBe(false); + expect(saveAccounts).not.toHaveBeenCalled(); + expect(showToast).not.toHaveBeenCalled(); + }); + it("updates storage and reports handled events for matching providers", async () => { const saveAccounts = vi.fn(); const setLastCodexCliActiveSyncIndex = vi.fn(); From 72fb084a8524568bf7cca1a0efa4da5d4dd9248d Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 17:07:23 +0800 Subject: [PATCH 124/376] fix(runtime): avoid stale live sync state writes --- index.ts | 4 +--- lib/runtime/live-sync.ts | 5 +++++ test/runtime-live-sync.test.ts | 5 +++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/index.ts b/index.ts index 6bbc5fa6..dbff48ab 100644 --- a/index.ts +++ b/index.ts @@ -484,9 +484,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { logWarn, pluginName: PLUGIN_NAME, }); - liveAccountSync = ensured.sync; - liveAccountSyncPath = ensured.path; - liveAccountSyncCleanupRegistered = ensured.cleanupRegistered; + void ensured; }; const ensureRefreshGuardian = ( diff --git a/lib/runtime/live-sync.ts b/lib/runtime/live-sync.ts index 7d4ef24a..c7859330 100644 --- a/lib/runtime/live-sync.ts +++ b/lib/runtime/live-sync.ts @@ -41,6 +41,11 @@ export async function ensureRuntimeLiveAccountSync< }> { if (!deps.getLiveAccountSync(deps.pluginConfig)) { deps.currentSync?.stop(); + deps.commitState({ + sync: null, + path: null, + cleanupRegistered: deps.currentCleanupRegistered, + }); return { sync: null, path: null, diff --git a/test/runtime-live-sync.test.ts b/test/runtime-live-sync.test.ts index 1bbe214f..d9861918 100644 --- a/test/runtime-live-sync.test.ts +++ b/test/runtime-live-sync.test.ts @@ -95,6 +95,11 @@ describe("runtime live sync", () => { cleanupRegistered: true, }); expect(currentSync.stop).toHaveBeenCalledTimes(1); + expect(deps.commitState).toHaveBeenCalledWith({ + sync: null, + path: null, + cleanupRegistered: true, + }); }); it("creates a sync, registers cleanup once, and skips redundant path switches", async () => { From a0b8de994fb27c2d7f217a15c48494d1ada68dcc Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 17:11:02 +0800 Subject: [PATCH 125/376] chore: add script typecheck lane --- package.json | 1 + scripts/codex-multi-auth.js | 2 + scripts/install-codex-auth-utils.js | 68 ++++++++++++++++----- scripts/install-codex-auth.js | 95 +++++++++++++++++++---------- tsconfig.scripts.json | 15 +++++ 5 files changed, 134 insertions(+), 47 deletions(-) create mode 100644 tsconfig.scripts.json diff --git a/package.json b/package.json index 35e1fcd8..24e9114b 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "scripts": { "build": "tsc && node scripts/copy-oauth-success.js", "typecheck": "tsc --noEmit", + "typecheck:scripts": "tsc -p tsconfig.scripts.json", "lint": "npm run lint:ts && npm run lint:scripts", "lint:ts": "eslint . --ext .ts", "lint:scripts": "eslint scripts --ext .js", diff --git a/scripts/codex-multi-auth.js b/scripts/codex-multi-auth.js index 3634ef30..411becc6 100755 --- a/scripts/codex-multi-auth.js +++ b/scripts/codex-multi-auth.js @@ -1,5 +1,7 @@ #!/usr/bin/env node +// @ts-check + import { createRequire } from "node:module"; import { runCodexMultiAuthCli } from "../dist/lib/codex-manager.js"; diff --git a/scripts/install-codex-auth-utils.js b/scripts/install-codex-auth-utils.js index 27e90cb2..828f3733 100644 --- a/scripts/install-codex-auth-utils.js +++ b/scripts/install-codex-auth-utils.js @@ -1,13 +1,26 @@ -import { join } from "node:path"; -import { homedir } from "node:os"; +// @ts-check + import { rename as fsRename } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; const PLUGIN_NAME = "codex-multi-auth"; -export const FILE_RETRY_CODES = new Set(["EBUSY", "EPERM", "EAGAIN", "ENOTEMPTY", "EACCES"]); +export const FILE_RETRY_CODES = new Set([ + "EBUSY", + "EPERM", + "EAGAIN", + "ENOTEMPTY", + "EACCES", +]); export const FILE_RETRY_MAX_ATTEMPTS = 6; export const FILE_RETRY_BASE_DELAY_MS = 25; export const FILE_RETRY_JITTER_MS = 20; +/** + * @param {NodeJS.Platform} [platform] + * @param {NodeJS.ProcessEnv} [env] + * @param {string} [home] + */ export function resolveInstallPaths( platform = process.platform, env = process.env, @@ -35,6 +48,7 @@ export function resolveInstallPaths( }; } +/** @param {unknown} list */ export function normalizePluginList(list) { const entries = Array.isArray(list) ? list.filter(Boolean) : []; const filtered = entries.filter((entry) => { @@ -44,7 +58,8 @@ export function normalizePluginList(list) { const deduped = []; const seen = new Set(); for (const entry of filtered) { - const key = typeof entry === "string" ? `s:${entry}` : `j:${JSON.stringify(entry)}`; + const key = + typeof entry === "string" ? `s:${entry}` : `j:${JSON.stringify(entry)}`; if (seen.has(key)) continue; seen.add(key); deduped.push(entry); @@ -52,6 +67,7 @@ export function normalizePluginList(list) { return [...deduped, PLUGIN_NAME]; } +/** @param {number} ms */ function sleep(ms) { // Keep this helper local so installer scripts remain standalone and do not depend on lib/. return new Promise((resolve) => { @@ -59,28 +75,49 @@ function sleep(ms) { }); } +/** @param {unknown} error */ function shouldRetryFileOperation(error) { - return error instanceof Error && - typeof error.code === "string" && - FILE_RETRY_CODES.has(error.code); + const fileError = /** @type {NodeJS.ErrnoException | undefined} */ (error); + return ( + fileError instanceof Error && + typeof fileError.code === "string" && + FILE_RETRY_CODES.has(fileError.code) + ); } +/** @template T @param {() => Promise} operation */ export async function withFileOperationRetry(operation) { for (let attempt = 1; ; attempt += 1) { try { return await operation(); } catch (error) { - if (!shouldRetryFileOperation(error) || attempt >= FILE_RETRY_MAX_ATTEMPTS) { + if ( + !shouldRetryFileOperation(error) || + attempt >= FILE_RETRY_MAX_ATTEMPTS + ) { throw error; } const jitter = Math.floor(Math.random() * FILE_RETRY_JITTER_MS); - const delayMs = (FILE_RETRY_BASE_DELAY_MS * (2 ** (attempt - 1))) + jitter; + const delayMs = FILE_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1) + jitter; await sleep(delayMs); } } } +/** + * @param {string} sourcePath + * @param {string} targetPath + * @param {{ + * rename?: (sourcePath: string, targetPath: string) => Promise, + * log?: (message: string) => void, + * maxRetries?: number, + * baseDelayMs?: number, + * jitterMs?: number, + * random?: () => number, + * sleep?: (ms: number) => Promise, + * }} [options] + */ export async function renameWithRetry(sourcePath, targetPath, options = {}) { const { rename = fsRename, @@ -101,14 +138,17 @@ export async function renameWithRetry(sourcePath, targetPath, options = {}) { await rename(sourcePath, targetPath); return; } catch (error) { - const code = error && typeof error === "object" && "code" in error - ? error.code - : undefined; - const isRetryable = typeof code === "string" && FILE_RETRY_CODES.has(code); + const code = + error && typeof error === "object" && "code" in error + ? error.code + : undefined; + const isRetryable = + typeof code === "string" && FILE_RETRY_CODES.has(code); if (!isRetryable || attempt === maxRetries - 1) { throw error; } - const delayMs = baseDelayMs * 2 ** attempt + Math.floor(random() * jitterMs); + const delayMs = + baseDelayMs * 2 ** attempt + Math.floor(random() * jitterMs); log( `Retrying atomic rename (${attempt + 1}/${maxRetries}) code=${code ?? "unknown"} source=${sourcePath} target=${targetPath} delayMs=${delayMs}`, ); diff --git a/scripts/install-codex-auth.js b/scripts/install-codex-auth.js index 5807c38c..d98614f4 100644 --- a/scripts/install-codex-auth.js +++ b/scripts/install-codex-auth.js @@ -1,9 +1,11 @@ #!/usr/bin/env node +// @ts-check + import { existsSync } from "node:fs"; -import { readFile, writeFile, mkdir, copyFile, rm } from "node:fs/promises"; -import { fileURLToPath } from "node:url"; +import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { normalizePluginList, renameWithRetry, @@ -16,17 +18,18 @@ const PLUGIN_NAME = "codex-multi-auth"; const args = new Set(process.argv.slice(2)); if (args.has("--help") || args.has("-h")) { - console.log(`Usage: ${PLUGIN_NAME} [--modern|--legacy] [--dry-run] [--no-cache-clear]\n\n` + - "Default behavior:\n" + - " - Installs/updates global config at ~/.config/Codex/Codex.json\n" + - " - Uses modern config (variants) by default\n" + - " - Ensures plugin is unpinned (latest)\n" + - " - Clears Codex plugin cache\n\n" + - "Options:\n" + - " --modern Force modern config (default)\n" + - " --legacy Use legacy config (older Codex versions)\n" + - " --dry-run Show actions without writing\n" + - " --no-cache-clear Skip clearing Codex cache\n" + console.log( + `Usage: ${PLUGIN_NAME} [--modern|--legacy] [--dry-run] [--no-cache-clear]\n\n` + + "Default behavior:\n" + + " - Installs/updates global config at ~/.config/Codex/Codex.json\n" + + " - Uses modern config (variants) by default\n" + + " - Ensures plugin is unpinned (latest)\n" + + " - Clears Codex plugin cache\n\n" + + "Options:\n" + + " --modern Force modern config (default)\n" + + " --legacy Use legacy config (older Codex versions)\n" + + " --dry-run Show actions without writing\n" + + " --no-cache-clear Skip clearing Codex cache\n", ); process.exit(0); } @@ -41,25 +44,35 @@ const repoRoot = resolve(scriptDir, ".."); const templatePath = join( repoRoot, "config", - useLegacy ? "codex-legacy.json" : "codex-modern.json" + useLegacy ? "codex-legacy.json" : "codex-modern.json", ); const installPaths = resolveInstallPaths(); -const { configDir, configPath, cacheNodeModules, cacheBunLock, cachePackageJson } = installPaths; - +const { + configDir, + configPath, + cacheNodeModules, + cacheBunLock, + cachePackageJson, +} = installPaths; + +/** @param {string} message */ function log(message) { console.log(message); } +/** @param {unknown} obj */ function formatJson(obj) { return `${JSON.stringify(obj, null, 2)}\n`; } +/** @param {string} filePath */ async function readJson(filePath) { const content = await readFile(filePath, "utf-8"); - return JSON.parse(content); + return /** @type {any} */ (JSON.parse(content)); } +/** @param {string} filePath @param {unknown} value */ async function writeJsonAtomic(filePath, value) { const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random() .toString(36) @@ -79,6 +92,7 @@ async function writeJsonAtomic(filePath, value) { } } +/** @param {string} sourcePath */ async function backupConfig(sourcePath) { const timestamp = new Date() .toISOString() @@ -98,6 +112,7 @@ async function removePluginFromCachePackage() { return; } + /** @type {any} */ let cacheData; try { cacheData = await readJson(cachePackageJson); @@ -145,7 +160,9 @@ async function clearCache() { log(`[dry-run] Would remove ${cacheBunLock}`); } else { try { - await withFileOperationRetry(() => rm(cacheNodeModules, { recursive: true, force: true })); + await withFileOperationRetry(() => + rm(cacheNodeModules, { recursive: true, force: true }), + ); await withFileOperationRetry(() => rm(cacheBunLock, { force: true })); } catch (error) { log( @@ -168,27 +185,36 @@ async function main() { let nextConfig = template; if (existsSync(configPath)) { const backupPath = await backupConfig(configPath); - log(`${dryRun ? "[dry-run] Would create backup" : "Backup created"}: ${backupPath}`); + log( + `${dryRun ? "[dry-run] Would create backup" : "Backup created"}: ${backupPath}`, + ); try { const existing = await readJson(configPath); const merged = { ...existing }; merged.plugin = normalizePluginList(existing.plugin); - const provider = (existing.provider && typeof existing.provider === "object") - ? { ...existing.provider } - : {}; - const existingOpenAi = provider.openai && typeof provider.openai === "object" - ? provider.openai - : {}; - const templateOpenAi = template.provider && typeof template.provider === "object" && - template.provider.openai && typeof template.provider.openai === "object" - ? template.provider.openai - : {}; + const provider = + existing.provider && typeof existing.provider === "object" + ? { ...existing.provider } + : {}; + const existingOpenAi = + provider.openai && typeof provider.openai === "object" + ? provider.openai + : {}; + const templateOpenAi = + template.provider && + typeof template.provider === "object" && + template.provider.openai && + typeof template.provider.openai === "object" + ? template.provider.openai + : {}; provider.openai = { ...templateOpenAi, ...existingOpenAi }; merged.provider = provider; nextConfig = merged; } catch (error) { - log(`Warning: Could not parse existing config (${error}). Replacing with template.`); + log( + `Warning: Could not parse existing config (${error}). Replacing with template.`, + ); nextConfig = template; } } else { @@ -196,7 +222,9 @@ async function main() { } if (dryRun) { - log(`[dry-run] Would write ${configPath} using ${useLegacy ? "legacy" : "modern"} config`); + log( + `[dry-run] Would write ${configPath} using ${useLegacy ? "legacy" : "modern"} config`, + ); } else { await mkdir(configDir, { recursive: true }); await writeJsonAtomic(configPath, nextConfig); @@ -222,8 +250,9 @@ const isDirectRun = (() => { if (isDirectRun) { main().catch((error) => { - console.error(`Installer failed: ${error instanceof Error ? error.message : error}`); + console.error( + `Installer failed: ${error instanceof Error ? error.message : error}`, + ); process.exit(1); }); } - diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json new file mode 100644 index 00000000..2b361288 --- /dev/null +++ b/tsconfig.scripts.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "noEmit": true, + "types": ["node"] + }, + "include": [ + "scripts/codex-multi-auth.js", + "scripts/install-codex-auth.js", + "scripts/install-codex-auth-utils.js", + "scripts/check-pack-budget.mjs" + ] +} From 9e0c8e7fbc296e6c697f8718b36584da2a9bae75 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 17:15:53 +0800 Subject: [PATCH 126/376] refactor: extract runtime session recovery helper --- index.ts | 14 +++++++------- lib/runtime/session-recovery.ts | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 7 deletions(-) create mode 100644 lib/runtime/session-recovery.ts diff --git a/index.ts b/index.ts index 20b72a5d..8ee04268 100644 --- a/index.ts +++ b/index.ts @@ -126,7 +126,6 @@ import { } from "./lib/prompts/codex.js"; import { prewarmHostCodexPrompt } from "./lib/prompts/host-codex-prompt.js"; import { - createSessionRecoveryHook, detectErrorType, getRecoveryToastContent, isRecoverableError, @@ -217,6 +216,7 @@ import { parseRuntimeRequestBody, } from "./lib/runtime/request-init.js"; import { ensureRuntimeSessionAffinity } from "./lib/runtime/session-affinity.js"; +import { createRuntimeSessionRecoveryHook } from "./lib/runtime/session-recovery.js"; import { getRuntimeStatusMarker } from "./lib/runtime/status-marker.js"; import { showRuntimeToast } from "./lib/runtime/toast.js"; import { @@ -704,12 +704,12 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } } - const recoveryHook = sessionRecoveryEnabled - ? createSessionRecoveryHook( - { client, directory: process.cwd() }, - { sessionRecovery: true, autoResume: autoResumeEnabled }, - ) - : null; + const recoveryHook = createRuntimeSessionRecoveryHook({ + enabled: sessionRecoveryEnabled, + client, + directory: process.cwd(), + autoResume: autoResumeEnabled, + }); checkAndNotify(async (message, variant) => { await showToast(message, variant); diff --git a/lib/runtime/session-recovery.ts b/lib/runtime/session-recovery.ts new file mode 100644 index 00000000..a6e709ae --- /dev/null +++ b/lib/runtime/session-recovery.ts @@ -0,0 +1,15 @@ +import { createSessionRecoveryHook } from "../recovery.js"; + +export function createRuntimeSessionRecoveryHook(deps: { + enabled: boolean; + client: unknown; + directory: string; + autoResume: boolean; +}) { + return deps.enabled + ? createSessionRecoveryHook( + { client: deps.client, directory: deps.directory }, + { sessionRecovery: true, autoResume: deps.autoResume }, + ) + : null; +} From df358e8c335e40304e3c6184f6bef405618522be Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 17:28:12 +0800 Subject: [PATCH 127/376] refactor: extract runtime account check helper --- index.ts | 338 ++++------------------------------- lib/runtime/account-check.ts | 335 ++++++++++++++++++++++++++++++++++ 2 files changed, 366 insertions(+), 307 deletions(-) create mode 100644 lib/runtime/account-check.ts diff --git a/index.ts b/index.ts index 8ee04268..f9f87d3c 100644 --- a/index.ts +++ b/index.ts @@ -159,10 +159,8 @@ import { applyFastSessionDefaults } from "./lib/request/request-transformer.js"; import { isEmptyResponse } from "./lib/request/response-handler.js"; import { withStreamingFailover } from "./lib/request/stream-failover.js"; import { addJitter } from "./lib/rotation.js"; -import { - type AccountCheckWorkingState, - createAccountCheckWorkingState, -} from "./lib/runtime/account-check-types.js"; +import { runRuntimeAccountCheck } from "./lib/runtime/account-check.js"; +import { createAccountCheckWorkingState } from "./lib/runtime/account-check-types.js"; import { clampRuntimeActiveIndices, isRuntimeFlaggableFailure, @@ -231,7 +229,6 @@ import { clearAccounts, clearFlaggedAccounts, exportAccounts, - type FlaggedAccountMetadataV1, findMatchingAccountIndex, formatStorageErrorHint, getStoragePath, @@ -2360,308 +2357,35 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { getUnsupportedCodexModelInfo, }); - const runAccountCheck = async ( - deepProbe: boolean, - ): Promise => { - const loadedStorage = await hydrateEmails(await loadAccounts()); - const workingStorage = loadedStorage - ? { - ...loadedStorage, - accounts: loadedStorage.accounts.map((account) => ({ - ...account, - })), - activeIndexByFamily: loadedStorage.activeIndexByFamily - ? { ...loadedStorage.activeIndexByFamily } - : {}, - } - : { - version: 3 as const, - accounts: [], - activeIndex: 0, - activeIndexByFamily: {}, - }; - - if (workingStorage.accounts.length === 0) { - console.log("\nNo accounts to check.\n"); - return; - } - - const flaggedStorage = await loadFlaggedAccounts(); - const state: AccountCheckWorkingState = - createAccountCheckWorkingState(flaggedStorage); - const total = workingStorage.accounts.length; - - console.log( - `\nChecking ${deepProbe ? "full account health" : "quotas"} for all accounts...\n`, - ); - - for (let i = 0; i < total; i += 1) { - const account = workingStorage.accounts[i]; - if (!account) continue; - const label = - account.email ?? account.accountLabel ?? `Account ${i + 1}`; - if (account.enabled === false) { - state.disabled += 1; - console.log(`[${i + 1}/${total}] ${label}: DISABLED`); - continue; - } - - try { - // If we already have a valid cached access token, don't force-refresh. - // This avoids flagging accounts where the refresh token has been burned - // but the access token is still valid (same behavior as Codex CLI). - const nowMs = Date.now(); - let accessToken: string | null = null; - let tokenAccountId: string | undefined; - let authDetail = "OK"; - if ( - account.accessToken && - (typeof account.expiresAt !== "number" || - !Number.isFinite(account.expiresAt) || - account.expiresAt > nowMs) - ) { - accessToken = account.accessToken; - authDetail = "OK (cached access)"; - - tokenAccountId = extractAccountId(account.accessToken); - if ( - tokenAccountId && - shouldUpdateAccountIdFromToken( - account.accountIdSource, - account.accountId, - ) && - tokenAccountId !== account.accountId - ) { - account.accountId = tokenAccountId; - account.accountIdSource = "token"; - state.storageChanged = true; - } - } - - // If Codex CLI has a valid cached access token for this email, use it - // instead of forcing a refresh. - if (!accessToken) { - const cached = await lookupCodexCliTokensByEmail( - account.email, - ); - if ( - cached && - (typeof cached.expiresAt !== "number" || - !Number.isFinite(cached.expiresAt) || - cached.expiresAt > nowMs) - ) { - accessToken = cached.accessToken; - authDetail = "OK (Codex CLI cache)"; - - if ( - cached.refreshToken && - cached.refreshToken !== account.refreshToken - ) { - account.refreshToken = cached.refreshToken; - state.storageChanged = true; - } - if ( - cached.accessToken && - cached.accessToken !== account.accessToken - ) { - account.accessToken = cached.accessToken; - state.storageChanged = true; - } - if (cached.expiresAt !== account.expiresAt) { - account.expiresAt = cached.expiresAt; - state.storageChanged = true; - } - - const hydratedEmail = sanitizeEmail( - extractAccountEmail(cached.accessToken), - ); - if (hydratedEmail && hydratedEmail !== account.email) { - account.email = hydratedEmail; - state.storageChanged = true; - } - - tokenAccountId = extractAccountId(cached.accessToken); - if ( - tokenAccountId && - shouldUpdateAccountIdFromToken( - account.accountIdSource, - account.accountId, - ) && - tokenAccountId !== account.accountId - ) { - account.accountId = tokenAccountId; - account.accountIdSource = "token"; - state.storageChanged = true; - } - } - } - - if (!accessToken) { - const refreshResult = await queuedRefresh( - account.refreshToken, - ); - if (refreshResult.type !== "success") { - state.errors += 1; - const message = - refreshResult.message ?? - refreshResult.reason ?? - "refresh failed"; - console.log( - `[${i + 1}/${total}] ${label}: ERROR (${message})`, - ); - if ( - deepProbe && - isRuntimeFlaggableFailure(refreshResult) - ) { - const existingIndex = - state.flaggedStorage.accounts.findIndex( - (flagged) => - flagged.refreshToken === account.refreshToken, - ); - const flaggedRecord: FlaggedAccountMetadataV1 = { - ...account, - flaggedAt: Date.now(), - flaggedReason: "token-invalid", - lastError: message, - }; - if (existingIndex >= 0) { - state.flaggedStorage.accounts[existingIndex] = - flaggedRecord; - } else { - state.flaggedStorage.accounts.push(flaggedRecord); - } - state.removeFromActive.add(account.refreshToken); - state.flaggedChanged = true; - } - continue; - } - - accessToken = refreshResult.access; - authDetail = "OK"; - if (refreshResult.refresh !== account.refreshToken) { - account.refreshToken = refreshResult.refresh; - state.storageChanged = true; - } - if ( - refreshResult.access && - refreshResult.access !== account.accessToken - ) { - account.accessToken = refreshResult.access; - state.storageChanged = true; - } - if ( - typeof refreshResult.expires === "number" && - refreshResult.expires !== account.expiresAt - ) { - account.expiresAt = refreshResult.expires; - state.storageChanged = true; - } - const hydratedEmail = sanitizeEmail( - extractAccountEmail( - refreshResult.access, - refreshResult.idToken, - ), - ); - if (hydratedEmail && hydratedEmail !== account.email) { - account.email = hydratedEmail; - state.storageChanged = true; - } - tokenAccountId = extractAccountId(refreshResult.access); - if ( - tokenAccountId && - shouldUpdateAccountIdFromToken( - account.accountIdSource, - account.accountId, - ) && - tokenAccountId !== account.accountId - ) { - account.accountId = tokenAccountId; - account.accountIdSource = "token"; - state.storageChanged = true; - } - } - - if (!accessToken) { - throw new Error("Missing access token after refresh"); - } - - if (deepProbe) { - state.ok += 1; - const detail = tokenAccountId - ? `${authDetail} (id:${tokenAccountId.slice(-6)})` - : authDetail; - console.log(`[${i + 1}/${total}] ${label}: ${detail}`); - continue; - } - - try { - const requestAccountId = - resolveRequestAccountId( - account.accountId, - account.accountIdSource, - tokenAccountId, - ) ?? - tokenAccountId ?? - account.accountId; - - if (!requestAccountId) { - throw new Error("Missing accountId for quota probe"); - } - - const snapshot = await fetchCodexQuotaSnapshot({ - accountId: requestAccountId, - accessToken, - }); - state.ok += 1; - console.log( - `[${i + 1}/${total}] ${label}: ${formatCodexQuotaLine(snapshot)}`, - ); - } catch (error) { - state.errors += 1; - const message = - error instanceof Error ? error.message : String(error); - console.log( - `[${i + 1}/${total}] ${label}: ERROR (${message.slice(0, 160)})`, - ); - } - } catch (error) { - state.errors += 1; - const message = - error instanceof Error ? error.message : String(error); - console.log( - `[${i + 1}/${total}] ${label}: ERROR (${message.slice(0, 120)})`, - ); - } - } - - if (state.removeFromActive.size > 0) { - workingStorage.accounts = workingStorage.accounts.filter( - (account) => - !state.removeFromActive.has(account.refreshToken), - ); - clampRuntimeActiveIndices(workingStorage, MODEL_FAMILIES); - state.storageChanged = true; - } - - if (state.storageChanged) { - await saveAccounts(workingStorage); - invalidateAccountManagerCache(); - } - if (state.flaggedChanged) { - await saveFlaggedAccounts(state.flaggedStorage); - } - - console.log(""); - console.log( - `Results: ${state.ok} ok, ${state.errors} error, ${state.disabled} disabled`, - ); - if (state.removeFromActive.size > 0) { - console.log( - `Moved ${state.removeFromActive.size} account(s) to flagged pool (invalid refresh token).`, - ); - } - console.log(""); - }; + const runAccountCheck = async (deepProbe: boolean): Promise => + runRuntimeAccountCheck(deepProbe, { + hydrateEmails, + loadAccounts, + createEmptyStorage: () => ({ + version: 3 as const, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }), + loadFlaggedAccounts, + createAccountCheckWorkingState, + lookupCodexCliTokensByEmail, + extractAccountId, + shouldUpdateAccountIdFromToken, + sanitizeEmail, + extractAccountEmail, + queuedRefresh, + isRuntimeFlaggableFailure, + fetchCodexQuotaSnapshot, + resolveRequestAccountId, + formatCodexQuotaLine, + clampRuntimeActiveIndices, + MODEL_FAMILIES, + saveAccounts, + invalidateAccountManagerCache, + saveFlaggedAccounts, + showLine: (message) => console.log(message), + }); if (!explicitLoginMode) { while (true) { diff --git a/lib/runtime/account-check.ts b/lib/runtime/account-check.ts new file mode 100644 index 00000000..69238200 --- /dev/null +++ b/lib/runtime/account-check.ts @@ -0,0 +1,335 @@ +import type { ModelFamily } from "../prompts/codex.js"; +import type { AccountStorageV3, FlaggedAccountMetadataV1 } from "../storage.js"; +import type { AccountIdSource, TokenResult } from "../types.js"; +import type { AccountCheckWorkingState } from "./account-check-types.js"; +import type { CodexQuotaSnapshot } from "./quota-headers.js"; + +export async function runRuntimeAccountCheck( + deepProbe: boolean, + deps: { + hydrateEmails: ( + storage: AccountStorageV3 | null, + ) => Promise; + loadAccounts: () => Promise; + createEmptyStorage: () => AccountStorageV3; + loadFlaggedAccounts: () => Promise<{ + version: 1; + accounts: FlaggedAccountMetadataV1[]; + }>; + createAccountCheckWorkingState: (flaggedStorage: { + version: 1; + accounts: FlaggedAccountMetadataV1[]; + }) => AccountCheckWorkingState; + lookupCodexCliTokensByEmail: (email: string | undefined) => Promise< + | { + refreshToken?: string; + accessToken: string; + expiresAt?: number; + } + | null + | undefined + >; + extractAccountId: (accessToken: string | undefined) => string | undefined; + shouldUpdateAccountIdFromToken: ( + source: AccountIdSource | undefined, + currentAccountId: string | undefined, + ) => boolean; + sanitizeEmail: (email: string | undefined) => string | undefined; + extractAccountEmail: ( + accessToken: string | undefined, + idToken?: string | undefined, + ) => string | undefined; + queuedRefresh: (refreshToken: string) => Promise; + isRuntimeFlaggableFailure: ( + failure: Extract, + ) => boolean; + fetchCodexQuotaSnapshot: (params: { + accountId: string; + accessToken: string; + }) => Promise; + resolveRequestAccountId: ( + accountId: string | undefined, + accountIdSource: AccountIdSource | undefined, + tokenAccountId: string | undefined, + ) => string | undefined; + formatCodexQuotaLine: (snapshot: CodexQuotaSnapshot) => string; + clampRuntimeActiveIndices: ( + storage: AccountStorageV3, + modelFamilies: readonly ModelFamily[], + ) => void; + MODEL_FAMILIES: readonly ModelFamily[]; + saveAccounts: (storage: AccountStorageV3) => Promise; + invalidateAccountManagerCache: () => void; + saveFlaggedAccounts: (storage: { + version: 1; + accounts: FlaggedAccountMetadataV1[]; + }) => Promise; + now?: () => number; + showLine: (message: string) => void; + }, +): Promise { + const loadedStorage = await deps.hydrateEmails(await deps.loadAccounts()); + const workingStorage = loadedStorage + ? { + ...loadedStorage, + accounts: loadedStorage.accounts.map((account) => ({ ...account })), + activeIndexByFamily: loadedStorage.activeIndexByFamily + ? { ...loadedStorage.activeIndexByFamily } + : {}, + } + : deps.createEmptyStorage(); + + if (workingStorage.accounts.length === 0) { + deps.showLine("\nNo accounts to check.\n"); + return; + } + + const flaggedStorage = await deps.loadFlaggedAccounts(); + const state = deps.createAccountCheckWorkingState(flaggedStorage); + const total = workingStorage.accounts.length; + + deps.showLine( + `\nChecking ${deepProbe ? "full account health" : "quotas"} for all accounts...\n`, + ); + + for (let i = 0; i < total; i += 1) { + const account = workingStorage.accounts[i]; + if (!account) continue; + const label = account.email ?? account.accountLabel ?? `Account ${i + 1}`; + if (account.enabled === false) { + state.disabled += 1; + deps.showLine(`[${i + 1}/${total}] ${label}: DISABLED`); + continue; + } + + try { + const nowMs = deps.now?.() ?? Date.now(); + let accessToken: string | null = null; + let tokenAccountId: string | undefined; + let authDetail = "OK"; + + if ( + account.accessToken && + (typeof account.expiresAt !== "number" || + !Number.isFinite(account.expiresAt) || + account.expiresAt > nowMs) + ) { + accessToken = account.accessToken; + authDetail = "OK (cached access)"; + tokenAccountId = deps.extractAccountId(account.accessToken); + if ( + tokenAccountId && + deps.shouldUpdateAccountIdFromToken( + account.accountIdSource, + account.accountId, + ) && + tokenAccountId !== account.accountId + ) { + account.accountId = tokenAccountId; + account.accountIdSource = "token"; + state.storageChanged = true; + } + } + + if (!accessToken) { + const cached = await deps.lookupCodexCliTokensByEmail(account.email); + if ( + cached && + (typeof cached.expiresAt !== "number" || + !Number.isFinite(cached.expiresAt) || + cached.expiresAt > nowMs) + ) { + accessToken = cached.accessToken; + authDetail = "OK (Codex CLI cache)"; + + if ( + cached.refreshToken && + cached.refreshToken !== account.refreshToken + ) { + account.refreshToken = cached.refreshToken; + state.storageChanged = true; + } + if ( + cached.accessToken && + cached.accessToken !== account.accessToken + ) { + account.accessToken = cached.accessToken; + state.storageChanged = true; + } + if (cached.expiresAt !== account.expiresAt) { + account.expiresAt = cached.expiresAt; + state.storageChanged = true; + } + + const hydratedEmail = deps.sanitizeEmail( + deps.extractAccountEmail(cached.accessToken), + ); + if (hydratedEmail && hydratedEmail !== account.email) { + account.email = hydratedEmail; + state.storageChanged = true; + } + + tokenAccountId = deps.extractAccountId(cached.accessToken); + if ( + tokenAccountId && + deps.shouldUpdateAccountIdFromToken( + account.accountIdSource, + account.accountId, + ) && + tokenAccountId !== account.accountId + ) { + account.accountId = tokenAccountId; + account.accountIdSource = "token"; + state.storageChanged = true; + } + } + } + + if (!accessToken) { + const refreshResult = await deps.queuedRefresh(account.refreshToken); + if (refreshResult.type !== "success") { + state.errors += 1; + const message = + refreshResult.message ?? refreshResult.reason ?? "refresh failed"; + deps.showLine(`[${i + 1}/${total}] ${label}: ERROR (${message})`); + if (deepProbe && deps.isRuntimeFlaggableFailure(refreshResult)) { + const existingIndex = state.flaggedStorage.accounts.findIndex( + (flagged) => flagged.refreshToken === account.refreshToken, + ); + const flaggedRecord: FlaggedAccountMetadataV1 = { + ...account, + flaggedAt: deps.now?.() ?? Date.now(), + flaggedReason: "token-invalid", + lastError: message, + }; + if (existingIndex >= 0) { + state.flaggedStorage.accounts[existingIndex] = flaggedRecord; + } else { + state.flaggedStorage.accounts.push(flaggedRecord); + } + state.removeFromActive.add(account.refreshToken); + state.flaggedChanged = true; + } + continue; + } + + accessToken = refreshResult.access; + authDetail = "OK"; + if (refreshResult.refresh !== account.refreshToken) { + account.refreshToken = refreshResult.refresh; + state.storageChanged = true; + } + if ( + refreshResult.access && + refreshResult.access !== account.accessToken + ) { + account.accessToken = refreshResult.access; + state.storageChanged = true; + } + if ( + typeof refreshResult.expires === "number" && + refreshResult.expires !== account.expiresAt + ) { + account.expiresAt = refreshResult.expires; + state.storageChanged = true; + } + const hydratedEmail = deps.sanitizeEmail( + deps.extractAccountEmail(refreshResult.access, refreshResult.idToken), + ); + if (hydratedEmail && hydratedEmail !== account.email) { + account.email = hydratedEmail; + state.storageChanged = true; + } + tokenAccountId = deps.extractAccountId(refreshResult.access); + if ( + tokenAccountId && + deps.shouldUpdateAccountIdFromToken( + account.accountIdSource, + account.accountId, + ) && + tokenAccountId !== account.accountId + ) { + account.accountId = tokenAccountId; + account.accountIdSource = "token"; + state.storageChanged = true; + } + } + + if (!accessToken) { + throw new Error("Missing access token after refresh"); + } + + if (deepProbe) { + state.ok += 1; + const detail = tokenAccountId + ? `${authDetail} (id:${tokenAccountId.slice(-6)})` + : authDetail; + deps.showLine(`[${i + 1}/${total}] ${label}: ${detail}`); + continue; + } + + try { + const requestAccountId = + deps.resolveRequestAccountId( + account.accountId, + account.accountIdSource, + tokenAccountId, + ) ?? + tokenAccountId ?? + account.accountId; + + if (!requestAccountId) { + throw new Error("Missing accountId for quota probe"); + } + + const snapshot = await deps.fetchCodexQuotaSnapshot({ + accountId: requestAccountId, + accessToken, + }); + state.ok += 1; + deps.showLine( + `[${i + 1}/${total}] ${label}: ${deps.formatCodexQuotaLine(snapshot)}`, + ); + } catch (error) { + state.errors += 1; + const message = error instanceof Error ? error.message : String(error); + deps.showLine( + `[${i + 1}/${total}] ${label}: ERROR (${message.slice(0, 160)})`, + ); + } + } catch (error) { + state.errors += 1; + const message = error instanceof Error ? error.message : String(error); + deps.showLine( + `[${i + 1}/${total}] ${label}: ERROR (${message.slice(0, 120)})`, + ); + } + } + + if (state.removeFromActive.size > 0) { + workingStorage.accounts = workingStorage.accounts.filter( + (account) => !state.removeFromActive.has(account.refreshToken), + ); + deps.clampRuntimeActiveIndices(workingStorage, deps.MODEL_FAMILIES); + state.storageChanged = true; + } + + if (state.storageChanged) { + await deps.saveAccounts(workingStorage); + deps.invalidateAccountManagerCache(); + } + if (state.flaggedChanged) { + await deps.saveFlaggedAccounts(state.flaggedStorage); + } + + deps.showLine(""); + deps.showLine( + `Results: ${state.ok} ok, ${state.errors} error, ${state.disabled} disabled`, + ); + if (state.removeFromActive.size > 0) { + deps.showLine( + `Moved ${state.removeFromActive.size} account(s) to flagged pool (invalid refresh token).`, + ); + } + deps.showLine(""); +} From b83a01f9cb7325d75be81ff1d79b09a925f44e64 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 17:36:51 +0800 Subject: [PATCH 128/376] refactor: extract storage migration helpers --- lib/storage.ts | 52 ++++++++--------------------- lib/storage/migration-helpers.ts | 56 ++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 39 deletions(-) create mode 100644 lib/storage/migration-helpers.ts diff --git a/lib/storage.ts b/lib/storage.ts index 9fd45cf0..624335e1 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -45,6 +45,10 @@ import { type AccountIdentityRef, toAccountIdentityRef, } from "./storage/identity.js"; +import { + loadNormalizedStorageFromPathOrNull, + mergeStorageForMigration, +} from "./storage/migration-helpers.js"; import { type AccountMetadataV1, type AccountMetadataV3, @@ -747,9 +751,13 @@ async function migrateLegacyProjectStorageIfNeeded( let migrated = false; for (const legacyPath of existingCandidatePaths) { - const legacyStorage = await loadNormalizedStorageFromPath( + const legacyStorage = await loadNormalizedStorageFromPathOrNull( legacyPath, "legacy account storage", + { + loadAccountsFromPath, + logWarn: (message, meta) => log.warn(message, meta), + }, ); if (!legacyStorage) { continue; @@ -758,6 +766,7 @@ async function migrateLegacyProjectStorageIfNeeded( const mergedStorage = mergeStorageForMigration( targetStorage, legacyStorage, + { normalizeAccountStorage }, ); const fallbackStorage = targetStorage ?? legacyStorage; @@ -813,45 +822,10 @@ async function loadNormalizedStorageFromPath( path: string, label: string, ): Promise { - try { - const { normalized, schemaErrors } = await loadAccountsFromPath(path); - if (schemaErrors.length > 0) { - log.warn(`${label} schema validation warnings`, { - path, - errors: schemaErrors.slice(0, 5), - }); - } - return normalized; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - log.warn(`Failed to load ${label}`, { - path, - error: String(error), - }); - } - return null; - } -} - -function mergeStorageForMigration( - current: AccountStorageV3 | null, - incoming: AccountStorageV3, -): AccountStorageV3 { - if (!current) { - return incoming; - } - - const merged = normalizeAccountStorage({ - version: 3, - activeIndex: current.activeIndex, - activeIndexByFamily: current.activeIndexByFamily, - accounts: [...current.accounts, ...incoming.accounts], + return loadNormalizedStorageFromPathOrNull(path, label, { + loadAccountsFromPath, + logWarn: (message, meta) => log.warn(message, meta), }); - if (!merged) { - return current; - } - return merged; } function selectNewestAccount( diff --git a/lib/storage/migration-helpers.ts b/lib/storage/migration-helpers.ts new file mode 100644 index 00000000..3bc050fb --- /dev/null +++ b/lib/storage/migration-helpers.ts @@ -0,0 +1,56 @@ +import type { AccountStorageV3 } from "../storage.js"; + +export async function loadNormalizedStorageFromPathOrNull( + path: string, + label: string, + deps: { + loadAccountsFromPath: (path: string) => Promise<{ + normalized: AccountStorageV3 | null; + schemaErrors: string[]; + }>; + logWarn: (message: string, meta: Record) => void; + }, +): Promise { + try { + const { normalized, schemaErrors } = await deps.loadAccountsFromPath(path); + if (schemaErrors.length > 0) { + deps.logWarn(`${label} schema validation warnings`, { + path, + errors: schemaErrors.slice(0, 5), + }); + } + return normalized; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + deps.logWarn(`Failed to load ${label}`, { + path, + error: String(error), + }); + } + return null; + } +} + +export function mergeStorageForMigration( + current: AccountStorageV3 | null, + incoming: AccountStorageV3, + deps: { + normalizeAccountStorage: (data: unknown) => AccountStorageV3 | null; + }, +): AccountStorageV3 { + if (!current) { + return incoming; + } + + const merged = deps.normalizeAccountStorage({ + version: 3, + activeIndex: current.activeIndex, + activeIndexByFamily: current.activeIndexByFamily, + accounts: [...current.accounts, ...incoming.accounts], + }); + if (!merged) { + return current; + } + return merged; +} From c64ce3f0ea683f5cce52de7420ed245513f2c984 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 17:42:59 +0800 Subject: [PATCH 129/376] refactor: extract storage restore assessment helpers --- lib/storage.ts | 118 ++++++--------------------- lib/storage/restore-assessment.ts | 131 ++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 93 deletions(-) create mode 100644 lib/storage/restore-assessment.ts diff --git a/lib/storage.ts b/lib/storage.ts index 624335e1..4c812b36 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -21,6 +21,10 @@ import { collectNamedBackups, type NamedBackupSummary, } from "./storage/named-backups.js"; +import { + buildRestoreAssessment, + collectBackupMetadata, +} from "./storage/restore-assessment.js"; import { describeAccountsWalSnapshot, describeFlaggedSnapshot, @@ -1231,113 +1235,41 @@ export async function loadAccounts(): Promise { export async function getBackupMetadata(): Promise { const storagePath = getStoragePath(); - const walPath = getAccountsWalPath(storagePath); - const accountCandidates = - await getAccountsBackupRecoveryCandidatesWithDiscovery(storagePath); - const accountSnapshots: BackupSnapshotMetadata[] = [ - await describeAccountSnapshot(storagePath, "accounts-primary"), - await describeAccountsWalSnapshot(walPath, { - statSnapshot, - readFile: fs.readFile, - isRecord, - computeSha256, - parseAndNormalizeStorage, - }), - ]; - for (const [index, candidate] of accountCandidates.entries()) { - const kind: BackupSnapshotKind = - candidate === `${storagePath}.bak` - ? "accounts-backup" - : candidate.startsWith(`${storagePath}.bak.`) - ? "accounts-backup-history" - : "accounts-discovered-backup"; - accountSnapshots.push( - await describeAccountSnapshot(candidate, kind, index), - ); - } - const flaggedPath = getFlaggedAccountsPath(); - const flaggedCandidates = - await getAccountsBackupRecoveryCandidatesWithDiscovery(flaggedPath); - const flaggedSnapshots: BackupSnapshotMetadata[] = [ - await describeFlaggedSnapshot(flaggedPath, "flagged-primary", { - statSnapshot, - loadFlaggedAccountsFromPath, - logWarn: (message, meta) => log.warn(message, meta), - }), - ]; - for (const [index, candidate] of flaggedCandidates.entries()) { - const kind: BackupSnapshotKind = - candidate === `${flaggedPath}.bak` - ? "flagged-backup" - : candidate.startsWith(`${flaggedPath}.bak.`) - ? "flagged-backup-history" - : "flagged-discovered-backup"; - flaggedSnapshots.push( - await describeFlaggedSnapshot(candidate, kind, { + return collectBackupMetadata({ + storagePath, + flaggedPath, + getAccountsWalPath, + getAccountsBackupRecoveryCandidatesWithDiscovery, + describeAccountSnapshot, + describeAccountsWalSnapshot: (path) => + describeAccountsWalSnapshot(path, { + statSnapshot, + readFile: fs.readFile, + isRecord, + computeSha256, + parseAndNormalizeStorage, + }), + describeFlaggedSnapshot: (path, kind, index) => + describeFlaggedSnapshot(path, kind, { index, statSnapshot, loadFlaggedAccountsFromPath, logWarn: (message, meta) => log.warn(message, meta), }), - ); - } - - return { - accounts: buildMetadataSection(storagePath, accountSnapshots), - flaggedAccounts: buildMetadataSection(flaggedPath, flaggedSnapshots), - }; + buildMetadataSection, + }); } export async function getRestoreAssessment(): Promise { const storagePath = getStoragePath(); const resetMarkerPath = getIntentionalResetMarkerPath(storagePath); const backupMetadata = await getBackupMetadata(); - if (existsSync(resetMarkerPath)) { - return { - storagePath, - restoreEligible: false, - restoreReason: "intentional-reset", - backupMetadata, - }; - } - const primarySnapshot = backupMetadata.accounts.snapshots.find( - (snapshot) => snapshot.kind === "accounts-primary", - ); - if (!primarySnapshot?.exists) { - return { - storagePath, - restoreEligible: true, - restoreReason: "missing-storage", - latestSnapshot: backupMetadata.accounts.latestValidPath - ? backupMetadata.accounts.snapshots.find( - (snapshot) => - snapshot.path === backupMetadata.accounts.latestValidPath, - ) - : undefined, - backupMetadata, - }; - } - if (primarySnapshot.valid && primarySnapshot.accountCount === 0) { - return { - storagePath, - restoreEligible: true, - restoreReason: "empty-storage", - latestSnapshot: primarySnapshot, - backupMetadata, - }; - } - return { + return buildRestoreAssessment({ storagePath, - restoreEligible: false, - latestSnapshot: backupMetadata.accounts.latestValidPath - ? backupMetadata.accounts.snapshots.find( - (snapshot) => - snapshot.path === backupMetadata.accounts.latestValidPath, - ) - : undefined, + resetMarkerExists: existsSync(resetMarkerPath), backupMetadata, - }; + }); } function parseAndNormalizeStorage(data: unknown): { diff --git a/lib/storage/restore-assessment.ts b/lib/storage/restore-assessment.ts new file mode 100644 index 00000000..fe456bff --- /dev/null +++ b/lib/storage/restore-assessment.ts @@ -0,0 +1,131 @@ +import type { BackupMetadata, RestoreAssessment } from "../storage.js"; +import type { BackupSnapshotMetadata } from "./backup-metadata.js"; + +type BackupSnapshotKind = BackupSnapshotMetadata["kind"]; + +export async function collectBackupMetadata(deps: { + storagePath: string; + flaggedPath: string; + getAccountsWalPath: (path: string) => string; + getAccountsBackupRecoveryCandidatesWithDiscovery: ( + path: string, + ) => Promise; + describeAccountSnapshot: ( + path: string, + kind: BackupSnapshotKind, + index?: number, + ) => Promise; + describeAccountsWalSnapshot: ( + path: string, + ) => Promise; + describeFlaggedSnapshot: ( + path: string, + kind: BackupSnapshotKind, + index?: number, + ) => Promise; + buildMetadataSection: ( + path: string, + snapshots: BackupSnapshotMetadata[], + ) => BackupMetadata["accounts"]; +}): Promise { + const walPath = deps.getAccountsWalPath(deps.storagePath); + const accountCandidates = + await deps.getAccountsBackupRecoveryCandidatesWithDiscovery( + deps.storagePath, + ); + const accountSnapshots: BackupSnapshotMetadata[] = [ + await deps.describeAccountSnapshot(deps.storagePath, "accounts-primary"), + await deps.describeAccountsWalSnapshot(walPath), + ]; + for (const [index, candidate] of accountCandidates.entries()) { + const kind: BackupSnapshotKind = + candidate === `${deps.storagePath}.bak` + ? "accounts-backup" + : candidate.startsWith(`${deps.storagePath}.bak.`) + ? "accounts-backup-history" + : "accounts-discovered-backup"; + accountSnapshots.push( + await deps.describeAccountSnapshot(candidate, kind, index), + ); + } + + const flaggedCandidates = + await deps.getAccountsBackupRecoveryCandidatesWithDiscovery( + deps.flaggedPath, + ); + const flaggedSnapshots: BackupSnapshotMetadata[] = [ + await deps.describeFlaggedSnapshot(deps.flaggedPath, "flagged-primary"), + ]; + for (const [index, candidate] of flaggedCandidates.entries()) { + const kind: BackupSnapshotKind = + candidate === `${deps.flaggedPath}.bak` + ? "flagged-backup" + : candidate.startsWith(`${deps.flaggedPath}.bak.`) + ? "flagged-backup-history" + : "flagged-discovered-backup"; + flaggedSnapshots.push( + await deps.describeFlaggedSnapshot(candidate, kind, index), + ); + } + + return { + accounts: deps.buildMetadataSection(deps.storagePath, accountSnapshots), + flaggedAccounts: deps.buildMetadataSection( + deps.flaggedPath, + flaggedSnapshots, + ), + }; +} + +export function buildRestoreAssessment(deps: { + storagePath: string; + resetMarkerExists: boolean; + backupMetadata: BackupMetadata; +}): RestoreAssessment { + if (deps.resetMarkerExists) { + return { + storagePath: deps.storagePath, + restoreEligible: false, + restoreReason: "intentional-reset", + backupMetadata: deps.backupMetadata, + }; + } + + const primarySnapshot = deps.backupMetadata.accounts.snapshots.find( + (snapshot) => snapshot.kind === "accounts-primary", + ); + if (!primarySnapshot?.exists) { + return { + storagePath: deps.storagePath, + restoreEligible: true, + restoreReason: "missing-storage", + latestSnapshot: deps.backupMetadata.accounts.latestValidPath + ? deps.backupMetadata.accounts.snapshots.find( + (snapshot) => + snapshot.path === deps.backupMetadata.accounts.latestValidPath, + ) + : undefined, + backupMetadata: deps.backupMetadata, + }; + } + if (primarySnapshot.valid && primarySnapshot.accountCount === 0) { + return { + storagePath: deps.storagePath, + restoreEligible: true, + restoreReason: "empty-storage", + latestSnapshot: primarySnapshot, + backupMetadata: deps.backupMetadata, + }; + } + return { + storagePath: deps.storagePath, + restoreEligible: false, + latestSnapshot: deps.backupMetadata.accounts.latestValidPath + ? deps.backupMetadata.accounts.snapshots.find( + (snapshot) => + snapshot.path === deps.backupMetadata.accounts.latestValidPath, + ) + : undefined, + backupMetadata: deps.backupMetadata, + }; +} From a3c1273eba5b4151d0e588c6e2df5b8cbc900b86 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 17:59:14 +0800 Subject: [PATCH 130/376] feat: add config explain command --- docs/reference/commands.md | 3 +- lib/codex-manager.ts | 12 + lib/codex-manager/commands/config-explain.ts | 37 ++ lib/config.ts | 425 +++++++++++++++++-- test/codex-manager-cli.test.ts | 63 +++ 5 files changed, 505 insertions(+), 35 deletions(-) create mode 100644 lib/codex-manager/commands/config-explain.ts diff --git a/docs/reference/commands.md b/docs/reference/commands.md index c69b3065..f84ca763 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -38,6 +38,7 @@ Compatibility aliases are supported: | `codex auth report` | Generate full health report | | `codex auth fix` | Apply safe account storage fixes | | `codex auth doctor` | Run diagnostics and optional repairs | +| `codex auth config explain` | Print effective config values and their sources | --- @@ -46,7 +47,7 @@ Compatibility aliases are supported: | Flag | Applies to | Meaning | | --- | --- | --- | | `--manual`, `--no-browser` | login | Skip browser launch and use manual callback flow | -| `--json` | verify-flagged, forecast, report, fix, doctor | Print machine-readable output | +| `--json` | verify-flagged, forecast, report, fix, doctor, config explain | Print machine-readable output | | `--live` | forecast, report, fix | Use live probe before decisions/output | | `--dry-run` | verify-flagged, fix, doctor | Preview without writing storage | | `--model ` | forecast, report, fix | Specify model for live probe paths | diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 7864539f..2c17232f 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -39,6 +39,7 @@ import { runBestCommand, } from "./codex-manager/commands/best.js"; import { runCheckCommand } from "./codex-manager/commands/check.js"; +import { runConfigExplainCommand } from "./codex-manager/commands/config-explain.js"; import { type DoctorCliOptions, runDoctorCommand, @@ -63,6 +64,7 @@ import { configureUnifiedSettings, resolveMenuLayoutMode, } from "./codex-manager/settings-hub.js"; +import { getPluginConfigExplainReport } from "./config.js"; import { ACCOUNT_LIMITS } from "./constants.js"; import { type DashboardAccountSortMode, @@ -3625,6 +3627,16 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { setCodexCliActiveSelection, }); } + if (command === "config") { + const [subcommand, ...configArgs] = rest; + if (subcommand === "explain") { + return runConfigExplainCommand(configArgs, { + getReport: getPluginConfigExplainReport, + }); + } + console.error(`Unknown config command: ${subcommand ?? "(missing)"}`); + return 1; + } console.error(`Unknown command: ${command}`); printUsage(); diff --git a/lib/codex-manager/commands/config-explain.ts b/lib/codex-manager/commands/config-explain.ts new file mode 100644 index 00000000..a47e8fac --- /dev/null +++ b/lib/codex-manager/commands/config-explain.ts @@ -0,0 +1,37 @@ +import type { ConfigExplainReport } from "../../config.js"; + +export function runConfigExplainCommand( + args: string[], + deps: { + getReport: () => ConfigExplainReport; + logInfo?: (message: string) => void; + logError?: (message: string) => void; + }, +): number { + const logInfo = deps.logInfo ?? console.log; + const logError = deps.logError ?? console.error; + const json = args.includes("--json"); + const unknown = args.filter((arg) => arg !== "--json"); + if (unknown.length > 0) { + logError(`Unknown option: ${unknown[0]}`); + return 1; + } + + const report = deps.getReport(); + if (json) { + logInfo(JSON.stringify(report, null, 2)); + return 0; + } + + logInfo(`Config storage: ${report.storageKind}`); + logInfo(`Config path: ${report.configPath ?? "(none)"}`); + logInfo(""); + for (const entry of report.entries) { + const envSuffix = + entry.envNames.length > 0 ? ` [${entry.envNames.join(", ")}]` : ""; + logInfo( + `${entry.key} = ${JSON.stringify(entry.value)} (${entry.source})${envSuffix}`, + ); + } + return 0; +} diff --git a/lib/config.ts b/lib/config.ts index f9e7ecf8..f806ec22 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -1,9 +1,13 @@ -import { readFileSync, existsSync, promises as fs } from "node:fs"; +import { existsSync, promises as fs, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; -import type { PluginConfig } from "./types.js"; import { logWarn } from "./logger.js"; -import { PluginConfigSchema, getValidationErrors } from "./schemas.js"; -import { getCodexHomeDir, getCodexMultiAuthDir, getLegacyCodexDir } from "./runtime-paths.js"; +import { + getCodexHomeDir, + getCodexMultiAuthDir, + getLegacyCodexDir, +} from "./runtime-paths.js"; +import { getValidationErrors, PluginConfigSchema } from "./schemas.js"; +import type { PluginConfig } from "./types.js"; import { getUnifiedSettingsPath, loadUnifiedPluginConfigSync, @@ -15,7 +19,10 @@ const CONFIG_PATH = join(CONFIG_DIR, "config.json"); const CODEX_HOME_DIR = getCodexHomeDir(); const LEGACY_CODEX_DIR = getLegacyCodexDir(); const IS_CUSTOM_CODEX_HOME = CODEX_HOME_DIR !== LEGACY_CODEX_DIR; -const LEGACY_CODEX_HOME_CONFIG_PATH = join(CODEX_HOME_DIR, "codex-multi-auth-config.json"); +const LEGACY_CODEX_HOME_CONFIG_PATH = join( + CODEX_HOME_DIR, + "codex-multi-auth-config.json", +); const LEGACY_CODEX_HOME_AUTH_CONFIG_PATH = join( CODEX_HOME_DIR, "openai-codex-auth-config.json", @@ -37,6 +44,22 @@ const RETRYABLE_FS_CODES = new Set(["EBUSY", "EPERM"]); export type UnsupportedCodexPolicy = "strict" | "fallback"; +export type ConfigExplainSource = "env" | "unified" | "file" | "default"; + +export interface ConfigExplainEntry { + key: keyof PluginConfig; + value: unknown; + defaultValue: unknown; + source: ConfigExplainSource; + envNames: string[]; +} + +export interface ConfigExplainReport { + configPath: string | null; + storageKind: "unified" | "file" | "none"; + entries: ConfigExplainEntry[]; +} + function logConfigWarnOnce(message: string): void { if (emittedConfigWarnings.has(message)) { return; @@ -309,7 +332,10 @@ async function writeJsonFileAtomicWithRetry( } } -async function withConfigSaveLock(path: string, task: () => Promise): Promise { +async function withConfigSaveLock( + path: string, + task: () => Promise, +): Promise { const previous = configSaveQueues.get(path) ?? Promise.resolve(); const queued = previous.catch(() => {}).then(task); configSaveQueues.set(path, queued); @@ -330,7 +356,9 @@ async function withConfigSaveLock(path: string, task: () => Promise): Prom * @param configPath - Filesystem path to the JSON config file. Concurrent writes may cause transient read/parse failures; callers should tolerate `null`. On Windows, path casing and exclusive locks can affect readability. * @returns The parsed top-level JSON object as a Record when the file exists and contains an object, or `null` if the file is missing, malformed, or could not be read. */ -function readConfigRecordFromPath(configPath: string): Record | null { +function readConfigRecordFromPath( + configPath: string, +): Record | null { if (!existsSync(configPath)) return null; try { const fileContent = readFileSync(configPath, "utf-8"); @@ -347,6 +375,36 @@ function readConfigRecordFromPath(configPath: string): Record | } } +function resolveStoredPluginConfigRecord(): { + configPath: string | null; + storageKind: "unified" | "file" | "none"; + record: Record | null; +} { + const unifiedConfig = loadUnifiedPluginConfigSync(); + if (isRecord(unifiedConfig)) { + return { + configPath: getUnifiedSettingsPath(), + storageKind: "unified", + record: unifiedConfig, + }; + } + + const configPath = resolvePluginConfigPath(); + if (!configPath) { + return { + configPath: null, + storageKind: "none", + record: null, + }; + } + + return { + configPath, + storageKind: "file", + record: readConfigRecordFromPath(configPath), + }; +} + /** * Prepare a partial PluginConfig for persistence by removing undefined values, * omitting non-finite numbers, and shallow-copying nested object records. @@ -357,7 +415,9 @@ function readConfigRecordFromPath(configPath: string): Record | * Concurrency: synchronous and side-effect free; callers are responsible for coordinating concurrent writes to the filesystem. * Filesystem: no Windows-specific path normalization or filesystem I/O is performed by this function. */ -function sanitizePluginConfigForSave(config: Partial): Record { +function sanitizePluginConfigForSave( + config: Partial, +): Record { const entries = Object.entries(config as Record); const sanitized: Record = {}; for (const [key, value] of entries) { @@ -385,7 +445,9 @@ function sanitizePluginConfigForSave(config: Partial): Record): Promise { +export async function savePluginConfig( + configPatch: Partial, +): Promise { const sanitizedPatch = sanitizePluginConfigForSave(configPatch); const envPath = (process.env.CODEX_MULTI_AUTH_CONFIG_PATH ?? "").trim(); @@ -405,7 +467,9 @@ export async function savePluginConfig(configPatch: Partial): Prom const unifiedConfig = loadUnifiedPluginConfigSync(); const legacyPath = unifiedConfig ? null : resolvePluginConfigPath(); const merged = { - ...(unifiedConfig ?? (legacyPath ? readConfigRecordFromPath(legacyPath) : null) ?? {}), + ...(unifiedConfig ?? + (legacyPath ? readConfigRecordFromPath(legacyPath) : null) ?? + {}), ...sanitizedPatch, }; await saveUnifiedPluginConfig(merged); @@ -549,14 +613,20 @@ export function getFastSession(pluginConfig: PluginConfig): boolean { ); } -export function getFastSessionStrategy(pluginConfig: PluginConfig): "hybrid" | "always" { - const env = (process.env.CODEX_AUTH_FAST_SESSION_STRATEGY ?? "").trim().toLowerCase(); +export function getFastSessionStrategy( + pluginConfig: PluginConfig, +): "hybrid" | "always" { + const env = (process.env.CODEX_AUTH_FAST_SESSION_STRATEGY ?? "") + .trim() + .toLowerCase(); if (env === "always") return "always"; if (env === "hybrid") return "hybrid"; return pluginConfig.fastSessionStrategy === "always" ? "always" : "hybrid"; } -export function getFastSessionMaxInputItems(pluginConfig: PluginConfig): number { +export function getFastSessionMaxInputItems( + pluginConfig: PluginConfig, +): number { return resolveNumberSetting( "CODEX_AUTH_FAST_SESSION_MAX_INPUT_ITEMS", pluginConfig.fastSessionMaxInputItems, @@ -565,7 +635,9 @@ export function getFastSessionMaxInputItems(pluginConfig: PluginConfig): number ); } -export function getRetryAllAccountsRateLimited(pluginConfig: PluginConfig): boolean { +export function getRetryAllAccountsRateLimited( + pluginConfig: PluginConfig, +): boolean { return resolveBooleanSetting( "CODEX_AUTH_RETRY_ALL_RATE_LIMITED", pluginConfig.retryAllAccountsRateLimited, @@ -573,7 +645,9 @@ export function getRetryAllAccountsRateLimited(pluginConfig: PluginConfig): bool ); } -export function getRetryAllAccountsMaxWaitMs(pluginConfig: PluginConfig): number { +export function getRetryAllAccountsMaxWaitMs( + pluginConfig: PluginConfig, +): number { return resolveNumberSetting( "CODEX_AUTH_RETRY_ALL_MAX_WAIT_MS", pluginConfig.retryAllAccountsMaxWaitMs, @@ -582,7 +656,9 @@ export function getRetryAllAccountsMaxWaitMs(pluginConfig: PluginConfig): number ); } -export function getRetryAllAccountsMaxRetries(pluginConfig: PluginConfig): number { +export function getRetryAllAccountsMaxRetries( + pluginConfig: PluginConfig, +): number { return resolveNumberSetting( "CODEX_AUTH_RETRY_ALL_MAX_RETRIES", pluginConfig.retryAllAccountsMaxRetries, @@ -594,7 +670,9 @@ export function getRetryAllAccountsMaxRetries(pluginConfig: PluginConfig): numbe export function getUnsupportedCodexPolicy( pluginConfig: PluginConfig, ): UnsupportedCodexPolicy { - const envPolicy = parseStringEnv(process.env.CODEX_AUTH_UNSUPPORTED_MODEL_POLICY); + const envPolicy = parseStringEnv( + process.env.CODEX_AUTH_UNSUPPORTED_MODEL_POLICY, + ); if (envPolicy && UNSUPPORTED_CODEX_POLICIES.has(envPolicy)) { return envPolicy as UnsupportedCodexPolicy; } @@ -615,19 +693,21 @@ export function getUnsupportedCodexPolicy( } if (typeof pluginConfig.fallbackOnUnsupportedCodexModel === "boolean") { - return pluginConfig.fallbackOnUnsupportedCodexModel - ? "fallback" - : "strict"; + return pluginConfig.fallbackOnUnsupportedCodexModel ? "fallback" : "strict"; } return "strict"; } -export function getFallbackOnUnsupportedCodexModel(pluginConfig: PluginConfig): boolean { +export function getFallbackOnUnsupportedCodexModel( + pluginConfig: PluginConfig, +): boolean { return getUnsupportedCodexPolicy(pluginConfig) === "fallback"; } -export function getFallbackToGpt52OnUnsupportedGpt53(pluginConfig: PluginConfig): boolean { +export function getFallbackToGpt52OnUnsupportedGpt53( + pluginConfig: PluginConfig, +): boolean { return resolveBooleanSetting( "CODEX_AUTH_FALLBACK_GPT53_TO_GPT52", pluginConfig.fallbackToGpt52OnUnsupportedGpt53, @@ -659,7 +739,9 @@ export function getUnsupportedCodexFallbackChain( if (!normalizedKey) continue; const targets = value - .map((target) => (typeof target === "string" ? normalizeModel(target) : "")) + .map((target) => + typeof target === "string" ? normalizeModel(target) : "", + ) .filter((target) => target.length > 0); if (targets.length > 0) { @@ -679,7 +761,9 @@ export function getTokenRefreshSkewMs(pluginConfig: PluginConfig): number { ); } -export function getRateLimitToastDebounceMs(pluginConfig: PluginConfig): number { +export function getRateLimitToastDebounceMs( + pluginConfig: PluginConfig, +): number { return resolveNumberSetting( "CODEX_AUTH_RATE_LIMIT_TOAST_DEBOUNCE_MS", pluginConfig.rateLimitToastDebounceMs, @@ -729,7 +813,9 @@ export function getParallelProbing(pluginConfig: PluginConfig): boolean { ); } -export function getParallelProbingMaxConcurrency(pluginConfig: PluginConfig): number { +export function getParallelProbingMaxConcurrency( + pluginConfig: PluginConfig, +): number { return resolveNumberSetting( "CODEX_AUTH_PARALLEL_PROBING_MAX_CONCURRENCY", pluginConfig.parallelProbingMaxConcurrency, @@ -747,7 +833,9 @@ export function getEmptyResponseMaxRetries(pluginConfig: PluginConfig): number { ); } -export function getEmptyResponseRetryDelayMs(pluginConfig: PluginConfig): number { +export function getEmptyResponseRetryDelayMs( + pluginConfig: PluginConfig, +): number { return resolveNumberSetting( "CODEX_AUTH_EMPTY_RESPONSE_RETRY_DELAY_MS", pluginConfig.emptyResponseRetryDelayMs, @@ -828,7 +916,9 @@ export function getLiveAccountSync(pluginConfig: PluginConfig): boolean { * Windows filesystem: value is independent of filesystem semantics. * Token redaction: this value contains no secrets and is safe to log. */ -export function getLiveAccountSyncDebounceMs(pluginConfig: PluginConfig): number { +export function getLiveAccountSyncDebounceMs( + pluginConfig: PluginConfig, +): number { return resolveNumberSetting( "CODEX_AUTH_LIVE_ACCOUNT_SYNC_DEBOUNCE_MS", pluginConfig.liveAccountSyncDebounceMs, @@ -908,7 +998,9 @@ export function getSessionAffinityTtlMs(pluginConfig: PluginConfig): number { * Filesystem: value is runtime-only and unaffected by Windows filesystem semantics. * Security: this setting contains no secrets and is safe to log; it does not include tokens or credentials. */ -export function getSessionAffinityMaxEntries(pluginConfig: PluginConfig): number { +export function getSessionAffinityMaxEntries( + pluginConfig: PluginConfig, +): number { return resolveNumberSetting( "CODEX_AUTH_SESSION_AFFINITY_MAX_ENTRIES", pluginConfig.sessionAffinityMaxEntries, @@ -927,7 +1019,9 @@ export function getSessionAffinityMaxEntries(pluginConfig: PluginConfig): number * @param pluginConfig - The plugin configuration object to read the setting from * @returns `true` if the proactive refresh guardian is enabled, `false` otherwise. */ -export function getProactiveRefreshGuardian(pluginConfig: PluginConfig): boolean { +export function getProactiveRefreshGuardian( + pluginConfig: PluginConfig, +): boolean { return resolveBooleanSetting( "CODEX_AUTH_PROACTIVE_GUARDIAN", pluginConfig.proactiveRefreshGuardian, @@ -948,7 +1042,9 @@ export function getProactiveRefreshGuardian(pluginConfig: PluginConfig): boolean * @param pluginConfig - Plugin configuration used as the fallback source for the interval value * @returns The proactive refresh interval in milliseconds (>= 5000) */ -export function getProactiveRefreshIntervalMs(pluginConfig: PluginConfig): number { +export function getProactiveRefreshIntervalMs( + pluginConfig: PluginConfig, +): number { return resolveNumberSetting( "CODEX_AUTH_PROACTIVE_GUARDIAN_INTERVAL_MS", pluginConfig.proactiveRefreshIntervalMs, @@ -967,7 +1063,9 @@ export function getProactiveRefreshIntervalMs(pluginConfig: PluginConfig): numbe * Windows filesystem: not related to filesystem behavior. * Token redaction: environment values and config contents may be redacted in logs and diagnostics. */ -export function getProactiveRefreshBufferMs(pluginConfig: PluginConfig): number { +export function getProactiveRefreshBufferMs( + pluginConfig: PluginConfig, +): number { return resolveNumberSetting( "CODEX_AUTH_PROACTIVE_GUARDIAN_BUFFER_MS", pluginConfig.proactiveRefreshBufferMs, @@ -1053,7 +1151,9 @@ export function getPreemptiveQuotaEnabled(pluginConfig: PluginConfig): boolean { * @param pluginConfig - Plugin configuration to read the setting from. The value may be overridden by the CODEX_AUTH_PREEMPTIVE_QUOTA_5H_REMAINING_PCT environment variable; environment override semantics are the same on Windows. Safe to call concurrently. The returned value does not contain sensitive tokens and requires no redaction. * @returns The percentage (0–100) used as the preemptive quota threshold for 5-hour intervals. */ -export function getPreemptiveQuotaRemainingPercent5h(pluginConfig: PluginConfig): number { +export function getPreemptiveQuotaRemainingPercent5h( + pluginConfig: PluginConfig, +): number { return resolveNumberSetting( "CODEX_AUTH_PREEMPTIVE_QUOTA_5H_REMAINING_PCT", pluginConfig.preemptiveQuotaRemainingPercent5h, @@ -1075,7 +1175,9 @@ export function getPreemptiveQuotaRemainingPercent5h(pluginConfig: PluginConfig) * @param pluginConfig - Plugin configuration object used as a fallback when the environment variable is not set * @returns The reserved quota percentage for the 7-day window, an integer between `0` and `100` */ -export function getPreemptiveQuotaRemainingPercent7d(pluginConfig: PluginConfig): number { +export function getPreemptiveQuotaRemainingPercent7d( + pluginConfig: PluginConfig, +): number { return resolveNumberSetting( "CODEX_AUTH_PREEMPTIVE_QUOTA_7D_REMAINING_PCT", pluginConfig.preemptiveQuotaRemainingPercent7d, @@ -1096,7 +1198,9 @@ export function getPreemptiveQuotaRemainingPercent7d(pluginConfig: PluginConfig) * Filesystem note: config persistence/visibility may differ on Windows vs POSIX filesystems. * Security: the returned value contains no sensitive tokens and is safe to log. */ -export function getPreemptiveQuotaMaxDeferralMs(pluginConfig: PluginConfig): number { +export function getPreemptiveQuotaMaxDeferralMs( + pluginConfig: PluginConfig, +): number { return resolveNumberSetting( "CODEX_AUTH_PREEMPTIVE_QUOTA_MAX_DEFERRAL_MS", pluginConfig.preemptiveQuotaMaxDeferralMs, @@ -1104,3 +1208,256 @@ export function getPreemptiveQuotaMaxDeferralMs(pluginConfig: PluginConfig): num { min: 1_000 }, ); } + +type ConfigExplainMeta = { + key: keyof PluginConfig; + envNames: string[]; + getValue: (pluginConfig: PluginConfig) => unknown; +}; + +const CONFIG_EXPLAIN_ENTRIES: ConfigExplainMeta[] = [ + { key: "codexMode", envNames: ["CODEX_MODE"], getValue: getCodexMode }, + { key: "codexTuiV2", envNames: ["CODEX_TUI_V2"], getValue: getCodexTuiV2 }, + { + key: "codexTuiColorProfile", + envNames: ["CODEX_TUI_COLOR_PROFILE"], + getValue: getCodexTuiColorProfile, + }, + { + key: "codexTuiGlyphMode", + envNames: ["CODEX_TUI_GLYPHS"], + getValue: getCodexTuiGlyphMode, + }, + { + key: "fastSession", + envNames: ["CODEX_AUTH_FAST_SESSION"], + getValue: getFastSession, + }, + { + key: "fastSessionStrategy", + envNames: ["CODEX_AUTH_FAST_SESSION_STRATEGY"], + getValue: getFastSessionStrategy, + }, + { + key: "fastSessionMaxInputItems", + envNames: ["CODEX_AUTH_FAST_SESSION_MAX_INPUT_ITEMS"], + getValue: getFastSessionMaxInputItems, + }, + { + key: "retryAllAccountsRateLimited", + envNames: ["CODEX_AUTH_RETRY_ALL_RATE_LIMITED"], + getValue: getRetryAllAccountsRateLimited, + }, + { + key: "retryAllAccountsMaxWaitMs", + envNames: ["CODEX_AUTH_RETRY_ALL_MAX_WAIT_MS"], + getValue: getRetryAllAccountsMaxWaitMs, + }, + { + key: "retryAllAccountsMaxRetries", + envNames: ["CODEX_AUTH_RETRY_ALL_MAX_RETRIES"], + getValue: getRetryAllAccountsMaxRetries, + }, + { + key: "unsupportedCodexPolicy", + envNames: [ + "CODEX_AUTH_UNSUPPORTED_MODEL_POLICY", + "CODEX_AUTH_FALLBACK_UNSUPPORTED_MODEL", + ], + getValue: getUnsupportedCodexPolicy, + }, + { + key: "fallbackOnUnsupportedCodexModel", + envNames: [ + "CODEX_AUTH_UNSUPPORTED_MODEL_POLICY", + "CODEX_AUTH_FALLBACK_UNSUPPORTED_MODEL", + ], + getValue: getFallbackOnUnsupportedCodexModel, + }, + { + key: "fallbackToGpt52OnUnsupportedGpt53", + envNames: ["CODEX_AUTH_FALLBACK_GPT53_TO_GPT52"], + getValue: getFallbackToGpt52OnUnsupportedGpt53, + }, + { + key: "unsupportedCodexFallbackChain", + envNames: [], + getValue: getUnsupportedCodexFallbackChain, + }, + { + key: "tokenRefreshSkewMs", + envNames: ["CODEX_AUTH_TOKEN_REFRESH_SKEW_MS"], + getValue: getTokenRefreshSkewMs, + }, + { + key: "rateLimitToastDebounceMs", + envNames: ["CODEX_AUTH_RATE_LIMIT_TOAST_DEBOUNCE_MS"], + getValue: getRateLimitToastDebounceMs, + }, + { + key: "toastDurationMs", + envNames: ["CODEX_AUTH_TOAST_DURATION_MS"], + getValue: getToastDurationMs, + }, + { + key: "perProjectAccounts", + envNames: ["CODEX_AUTH_PER_PROJECT_ACCOUNTS"], + getValue: getPerProjectAccounts, + }, + { + key: "sessionRecovery", + envNames: ["CODEX_AUTH_SESSION_RECOVERY"], + getValue: getSessionRecovery, + }, + { + key: "autoResume", + envNames: ["CODEX_AUTH_AUTO_RESUME"], + getValue: getAutoResume, + }, + { + key: "parallelProbing", + envNames: ["CODEX_AUTH_PARALLEL_PROBING"], + getValue: getParallelProbing, + }, + { + key: "parallelProbingMaxConcurrency", + envNames: ["CODEX_AUTH_PARALLEL_PROBING_MAX_CONCURRENCY"], + getValue: getParallelProbingMaxConcurrency, + }, + { + key: "emptyResponseMaxRetries", + envNames: ["CODEX_AUTH_EMPTY_RESPONSE_MAX_RETRIES"], + getValue: getEmptyResponseMaxRetries, + }, + { + key: "emptyResponseRetryDelayMs", + envNames: ["CODEX_AUTH_EMPTY_RESPONSE_RETRY_DELAY_MS"], + getValue: getEmptyResponseRetryDelayMs, + }, + { + key: "pidOffsetEnabled", + envNames: ["CODEX_AUTH_PID_OFFSET_ENABLED"], + getValue: getPidOffsetEnabled, + }, + { + key: "fetchTimeoutMs", + envNames: ["CODEX_AUTH_FETCH_TIMEOUT_MS"], + getValue: getFetchTimeoutMs, + }, + { + key: "streamStallTimeoutMs", + envNames: ["CODEX_AUTH_STREAM_STALL_TIMEOUT_MS"], + getValue: getStreamStallTimeoutMs, + }, + { + key: "liveAccountSync", + envNames: ["CODEX_AUTH_LIVE_ACCOUNT_SYNC"], + getValue: getLiveAccountSync, + }, + { + key: "liveAccountSyncDebounceMs", + envNames: ["CODEX_AUTH_LIVE_ACCOUNT_SYNC_DEBOUNCE_MS"], + getValue: getLiveAccountSyncDebounceMs, + }, + { + key: "liveAccountSyncPollMs", + envNames: ["CODEX_AUTH_LIVE_ACCOUNT_SYNC_POLL_MS"], + getValue: getLiveAccountSyncPollMs, + }, + { + key: "sessionAffinity", + envNames: ["CODEX_AUTH_SESSION_AFFINITY"], + getValue: getSessionAffinity, + }, + { + key: "sessionAffinityTtlMs", + envNames: ["CODEX_AUTH_SESSION_AFFINITY_TTL_MS"], + getValue: getSessionAffinityTtlMs, + }, + { + key: "sessionAffinityMaxEntries", + envNames: ["CODEX_AUTH_SESSION_AFFINITY_MAX_ENTRIES"], + getValue: getSessionAffinityMaxEntries, + }, + { + key: "proactiveRefreshGuardian", + envNames: ["CODEX_AUTH_PROACTIVE_GUARDIAN"], + getValue: getProactiveRefreshGuardian, + }, + { + key: "proactiveRefreshIntervalMs", + envNames: ["CODEX_AUTH_PROACTIVE_GUARDIAN_INTERVAL_MS"], + getValue: getProactiveRefreshIntervalMs, + }, + { + key: "proactiveRefreshBufferMs", + envNames: ["CODEX_AUTH_PROACTIVE_GUARDIAN_BUFFER_MS"], + getValue: getProactiveRefreshBufferMs, + }, + { + key: "networkErrorCooldownMs", + envNames: ["CODEX_AUTH_NETWORK_ERROR_COOLDOWN_MS"], + getValue: getNetworkErrorCooldownMs, + }, + { + key: "serverErrorCooldownMs", + envNames: ["CODEX_AUTH_SERVER_ERROR_COOLDOWN_MS"], + getValue: getServerErrorCooldownMs, + }, + { + key: "storageBackupEnabled", + envNames: ["CODEX_AUTH_STORAGE_BACKUP_ENABLED"], + getValue: getStorageBackupEnabled, + }, + { + key: "preemptiveQuotaEnabled", + envNames: ["CODEX_AUTH_PREEMPTIVE_QUOTA_ENABLED"], + getValue: getPreemptiveQuotaEnabled, + }, + { + key: "preemptiveQuotaRemainingPercent5h", + envNames: ["CODEX_AUTH_PREEMPTIVE_QUOTA_5H_REMAINING_PCT"], + getValue: getPreemptiveQuotaRemainingPercent5h, + }, + { + key: "preemptiveQuotaRemainingPercent7d", + envNames: ["CODEX_AUTH_PREEMPTIVE_QUOTA_7D_REMAINING_PCT"], + getValue: getPreemptiveQuotaRemainingPercent7d, + }, + { + key: "preemptiveQuotaMaxDeferralMs", + envNames: ["CODEX_AUTH_PREEMPTIVE_QUOTA_MAX_DEFERRAL_MS"], + getValue: getPreemptiveQuotaMaxDeferralMs, + }, +]; + +export function getPluginConfigExplainReport(): ConfigExplainReport { + const pluginConfig = loadPluginConfig(); + const stored = resolveStoredPluginConfigRecord(); + const storedRecord = stored.record ?? null; + const entries = CONFIG_EXPLAIN_ENTRIES.map((entry) => { + const source: ConfigExplainSource = entry.envNames.some( + (name) => process.env[name] !== undefined, + ) + ? "env" + : storedRecord && Object.hasOwn(storedRecord, entry.key) + ? stored.storageKind === "none" + ? "default" + : stored.storageKind + : "default"; + + return { + key: entry.key, + value: entry.getValue(pluginConfig), + defaultValue: DEFAULT_PLUGIN_CONFIG[entry.key], + source, + envNames: entry.envNames, + }; + }); + + return { + configPath: stored.configPath, + storageKind: stored.storageKind, + entries, + }; +} diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 6a6a824d..bc41d910 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -613,6 +613,69 @@ describe("codex manager cli commands", () => { expect(setStoragePathMock).toHaveBeenCalledWith(null); }); + it("prints config explain output in json mode", async () => { + loadPluginConfigMock.mockReturnValue({ + codexMode: true, + codexTuiV2: true, + codexTuiColorProfile: "truecolor", + codexTuiGlyphMode: "ascii", + fastSession: false, + fastSessionStrategy: "hybrid", + fastSessionMaxInputItems: 30, + retryAllAccountsRateLimited: true, + retryAllAccountsMaxWaitMs: 0, + retryAllAccountsMaxRetries: Infinity, + unsupportedCodexPolicy: "strict", + fallbackOnUnsupportedCodexModel: false, + fallbackToGpt52OnUnsupportedGpt53: true, + unsupportedCodexFallbackChain: {}, + tokenRefreshSkewMs: 60000, + rateLimitToastDebounceMs: 60000, + toastDurationMs: 5000, + perProjectAccounts: true, + sessionRecovery: true, + autoResume: true, + parallelProbing: false, + parallelProbingMaxConcurrency: 2, + emptyResponseMaxRetries: 2, + emptyResponseRetryDelayMs: 1000, + pidOffsetEnabled: false, + fetchTimeoutMs: 60000, + streamStallTimeoutMs: 45000, + liveAccountSync: true, + liveAccountSyncDebounceMs: 250, + liveAccountSyncPollMs: 2000, + sessionAffinity: true, + sessionAffinityTtlMs: 1200000, + sessionAffinityMaxEntries: 512, + proactiveRefreshGuardian: true, + proactiveRefreshIntervalMs: 60000, + proactiveRefreshBufferMs: 300000, + networkErrorCooldownMs: 6000, + serverErrorCooldownMs: 4000, + storageBackupEnabled: true, + preemptiveQuotaEnabled: true, + preemptiveQuotaRemainingPercent5h: 5, + preemptiveQuotaRemainingPercent7d: 5, + preemptiveQuotaMaxDeferralMs: 7200000, + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli([ + "auth", + "config", + "explain", + "--json", + ]); + + expect(exitCode).toBe(0); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('"storageKind"'), + ); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"entries"')); + }); + it("prints populated account status for auth status", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValueOnce({ From 3c06b703c8ef0767752108c528446aff30ff5a6e Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 18:05:37 +0800 Subject: [PATCH 131/376] feat: add debug bundle command --- docs/reference/commands.md | 3 +- lib/codex-manager.ts | 17 +++++ lib/codex-manager/commands/debug-bundle.ts | 89 ++++++++++++++++++++++ test/codex-manager-cli.test.ts | 73 ++++++++++++++++++ 4 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 lib/codex-manager/commands/debug-bundle.ts diff --git a/docs/reference/commands.md b/docs/reference/commands.md index f84ca763..fce08be7 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -39,6 +39,7 @@ Compatibility aliases are supported: | `codex auth fix` | Apply safe account storage fixes | | `codex auth doctor` | Run diagnostics and optional repairs | | `codex auth config explain` | Print effective config values and their sources | +| `codex auth debug bundle` | Print a bundled runtime/debug snapshot | --- @@ -47,7 +48,7 @@ Compatibility aliases are supported: | Flag | Applies to | Meaning | | --- | --- | --- | | `--manual`, `--no-browser` | login | Skip browser launch and use manual callback flow | -| `--json` | verify-flagged, forecast, report, fix, doctor, config explain | Print machine-readable output | +| `--json` | verify-flagged, forecast, report, fix, doctor, config explain, debug bundle | Print machine-readable output | | `--live` | forecast, report, fix | Use live probe before decisions/output | | `--dry-run` | verify-flagged, fix, doctor | Preview without writing storage | | `--model ` | forecast, report, fix | Specify model for live probe paths | diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 2c17232f..c30ad591 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -40,6 +40,7 @@ import { } from "./codex-manager/commands/best.js"; import { runCheckCommand } from "./codex-manager/commands/check.js"; import { runConfigExplainCommand } from "./codex-manager/commands/config-explain.js"; +import { runDebugBundleCommand } from "./codex-manager/commands/debug-bundle.js"; import { type DoctorCliOptions, runDoctorCommand, @@ -100,6 +101,7 @@ import { type FlaggedAccountMetadataV1, findMatchingAccountIndex, formatStorageErrorHint, + getLastAccountsSaveTimestamp, getNamedBackups, getStoragePath, loadAccounts, @@ -3637,6 +3639,21 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { console.error(`Unknown config command: ${subcommand ?? "(missing)"}`); return 1; } + if (command === "debug") { + const [subcommand, ...debugArgs] = rest; + if (subcommand === "bundle") { + return runDebugBundleCommand(debugArgs, { + getConfigReport: getPluginConfigExplainReport, + getStoragePath, + loadAccounts, + loadFlaggedAccounts, + loadCodexCliState, + getLastAccountsSaveTimestamp, + }); + } + console.error(`Unknown debug command: ${subcommand ?? "(missing)"}`); + return 1; + } console.error(`Unknown command: ${command}`); printUsage(); diff --git a/lib/codex-manager/commands/debug-bundle.ts b/lib/codex-manager/commands/debug-bundle.ts new file mode 100644 index 00000000..8e6d9770 --- /dev/null +++ b/lib/codex-manager/commands/debug-bundle.ts @@ -0,0 +1,89 @@ +import type { ConfigExplainReport } from "../../config.js"; + +export function runDebugBundleCommand( + args: string[], + deps: { + getConfigReport: () => ConfigExplainReport; + getStoragePath: () => string; + loadAccounts: () => Promise<{ + accounts: Array<{ enabled?: boolean }>; + activeIndex?: number; + } | null>; + loadFlaggedAccounts: () => Promise<{ accounts: unknown[] }>; + loadCodexCliState: (options: { forceRefresh: boolean }) => Promise<{ + path: string; + accounts: unknown[]; + activeEmail?: string; + activeAccountId?: string; + syncVersion?: number; + sourceUpdatedAtMs?: number; + } | null>; + getLastAccountsSaveTimestamp: () => number; + logInfo?: (message: string) => void; + logError?: (message: string) => void; + }, +): Promise { + const logInfo = deps.logInfo ?? console.log; + const logError = deps.logError ?? console.error; + const json = args.includes("--json"); + const unknown = args.filter((arg) => arg !== "--json"); + if (unknown.length > 0) { + logError(`Unknown option: ${unknown[0]}`); + return Promise.resolve(1); + } + + return Promise.all([ + Promise.resolve(deps.getConfigReport()), + deps.loadAccounts(), + deps.loadFlaggedAccounts(), + deps.loadCodexCliState({ forceRefresh: true }), + ]).then(([config, accounts, flagged, codexCli]) => { + const bundle = { + generatedAt: new Date().toISOString(), + storagePath: deps.getStoragePath(), + lastAccountsSaveTimestamp: deps.getLastAccountsSaveTimestamp(), + config, + accounts: { + total: accounts?.accounts.length ?? 0, + enabled: + accounts?.accounts.filter((account) => account.enabled !== false) + .length ?? 0, + activeIndex: + typeof accounts?.activeIndex === "number" + ? accounts.activeIndex + 1 + : null, + }, + flaggedAccounts: { + total: flagged.accounts.length, + }, + codexCli: codexCli + ? { + path: codexCli.path, + accountCount: codexCli.accounts.length, + activeEmail: codexCli.activeEmail ?? null, + activeAccountId: codexCli.activeAccountId ?? null, + syncVersion: codexCli.syncVersion ?? null, + sourceUpdatedAtMs: codexCli.sourceUpdatedAtMs ?? null, + } + : null, + }; + + if (json) { + logInfo(JSON.stringify(bundle, null, 2)); + return 0; + } + + logInfo(`Generated: ${bundle.generatedAt}`); + logInfo(`Storage: ${bundle.storagePath}`); + logInfo( + `Accounts: ${bundle.accounts.total} total, ${bundle.accounts.enabled} enabled`, + ); + logInfo(`Flagged: ${bundle.flaggedAccounts.total}`); + if (bundle.codexCli) { + logInfo( + `Codex CLI: ${bundle.codexCli.accountCount} account(s), active ${bundle.codexCli.activeEmail ?? "unknown"}`, + ); + } + return 0; + }); +} diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index bc41d910..14d4d4d5 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -676,6 +676,79 @@ describe("codex manager cli commands", () => { expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"entries"')); }); + it("prints debug bundle output in json mode", async () => { + loadPluginConfigMock.mockReturnValue({ + codexMode: true, + codexTuiV2: true, + codexTuiColorProfile: "truecolor", + codexTuiGlyphMode: "ascii", + fastSession: false, + fastSessionStrategy: "hybrid", + fastSessionMaxInputItems: 30, + retryAllAccountsRateLimited: true, + retryAllAccountsMaxWaitMs: 0, + retryAllAccountsMaxRetries: Infinity, + unsupportedCodexPolicy: "strict", + fallbackOnUnsupportedCodexModel: false, + fallbackToGpt52OnUnsupportedGpt53: true, + unsupportedCodexFallbackChain: {}, + tokenRefreshSkewMs: 60000, + rateLimitToastDebounceMs: 60000, + toastDurationMs: 5000, + perProjectAccounts: true, + sessionRecovery: true, + autoResume: true, + parallelProbing: false, + parallelProbingMaxConcurrency: 2, + emptyResponseMaxRetries: 2, + emptyResponseRetryDelayMs: 1000, + pidOffsetEnabled: false, + fetchTimeoutMs: 60000, + streamStallTimeoutMs: 45000, + liveAccountSync: true, + liveAccountSyncDebounceMs: 250, + liveAccountSyncPollMs: 2000, + sessionAffinity: true, + sessionAffinityTtlMs: 1200000, + sessionAffinityMaxEntries: 512, + proactiveRefreshGuardian: true, + proactiveRefreshIntervalMs: 60000, + proactiveRefreshBufferMs: 300000, + networkErrorCooldownMs: 6000, + serverErrorCooldownMs: 4000, + storageBackupEnabled: true, + preemptiveQuotaEnabled: true, + preemptiveQuotaRemainingPercent5h: 5, + preemptiveQuotaRemainingPercent7d: 5, + preemptiveQuotaMaxDeferralMs: 7200000, + }); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + accounts: [{ refreshToken: "token-1", enabled: true }], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }); + loadFlaggedAccountsMock.mockResolvedValueOnce({ + version: 1, + accounts: [{ refreshToken: "flagged-1" }], + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli([ + "auth", + "debug", + "bundle", + "--json", + ]); + + expect(exitCode).toBe(0); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('"storagePath"'), + ); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"codexCli"')); + }); + it("prints populated account status for auth status", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValueOnce({ From 7f96ecd7e217c03adefe739ca349a006a091c569 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 18:19:39 +0800 Subject: [PATCH 132/376] feat: add init-config command --- docs/reference/commands.md | 2 + lib/codex-manager.ts | 7 ++ lib/codex-manager/commands/init-config.ts | 104 ++++++++++++++++++++++ test/codex-manager-cli.test.ts | 16 ++++ 4 files changed, 129 insertions(+) create mode 100644 lib/codex-manager/commands/init-config.ts diff --git a/docs/reference/commands.md b/docs/reference/commands.md index fce08be7..1607a83c 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -39,6 +39,7 @@ Compatibility aliases are supported: | `codex auth fix` | Apply safe account storage fixes | | `codex auth doctor` | Run diagnostics and optional repairs | | `codex auth config explain` | Print effective config values and their sources | +| `codex auth init-config [modern|legacy|minimal]` | Print a starter config template | | `codex auth debug bundle` | Print a bundled runtime/debug snapshot | --- @@ -53,6 +54,7 @@ Compatibility aliases are supported: | `--dry-run` | verify-flagged, fix, doctor | Preview without writing storage | | `--model ` | forecast, report, fix | Specify model for live probe paths | | `--out ` | report | Write report output to file | +| `--write ` | init-config, config template | Write template output to a file instead of stdout | | `--fix` | doctor | Apply safe repairs | | `--no-restore` | verify-flagged | Verify only; do not restore healthy flagged accounts | diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index c30ad591..91840f0f 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -50,6 +50,7 @@ import { runFixCommand, } from "./codex-manager/commands/fix.js"; import { runForecastCommand } from "./codex-manager/commands/forecast.js"; +import { runInitConfigCommand } from "./codex-manager/commands/init-config.js"; import { runReportCommand } from "./codex-manager/commands/report.js"; import { runFeaturesCommand, @@ -3636,9 +3637,15 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { getReport: getPluginConfigExplainReport, }); } + if (subcommand === "template") { + return runInitConfigCommand(configArgs); + } console.error(`Unknown config command: ${subcommand ?? "(missing)"}`); return 1; } + if (command === "init-config") { + return runInitConfigCommand(rest); + } if (command === "debug") { const [subcommand, ...debugArgs] = rest; if (subcommand === "bundle") { diff --git a/lib/codex-manager/commands/init-config.ts b/lib/codex-manager/commands/init-config.ts new file mode 100644 index 00000000..222cce72 --- /dev/null +++ b/lib/codex-manager/commands/init-config.ts @@ -0,0 +1,104 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const TEMPLATE_MAP = { + modern: "codex-modern.json", + legacy: "codex-legacy.json", + minimal: "minimal-codex.json", +} as const; + +type TemplateName = keyof typeof TEMPLATE_MAP; + +type ParsedArgs = + | { ok: true; template: TemplateName; stdout: boolean; writePath?: string } + | { ok: false; message: string }; + +function parseArgs(args: string[]): ParsedArgs { + let template: TemplateName = "modern"; + let stdout = true; + let writePath: string | undefined; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (!arg) continue; + if (arg === "modern" || arg === "legacy" || arg === "minimal") { + template = arg; + continue; + } + if (arg === "--stdout") { + stdout = true; + continue; + } + if (arg === "--write") { + const next = args[i + 1]; + if (!next) return { ok: false, message: "Missing value for --write" }; + writePath = next; + stdout = false; + i += 1; + continue; + } + if (arg.startsWith("--write=")) { + const value = arg.slice("--write=".length).trim(); + if (!value) return { ok: false, message: "Missing value for --write" }; + writePath = value; + stdout = false; + continue; + } + return { ok: false, message: `Unknown option: ${arg}` }; + } + + return { ok: true, template, stdout, writePath }; +} + +export async function runInitConfigCommand( + args: string[], + deps?: { + logInfo?: (message: string) => void; + logError?: (message: string) => void; + readTemplate?: (template: TemplateName) => Promise; + writeTemplate?: (path: string, content: string) => Promise; + cwd?: () => string; + }, +): Promise { + const logInfo = deps?.logInfo ?? console.log; + const logError = deps?.logError ?? console.error; + const cwd = deps?.cwd?.() ?? process.cwd(); + + const parsed = parseArgs(args); + if (!parsed.ok) { + logError(parsed.message); + return 1; + } + + const readTemplate = + deps?.readTemplate ?? + (async (template: TemplateName) => { + const currentFile = fileURLToPath(import.meta.url); + const repoRoot = resolve(dirname(currentFile), "../../../"); + const templatePath = resolve(repoRoot, "config", TEMPLATE_MAP[template]); + return readFile(templatePath, "utf8"); + }); + + const writeTemplate = + deps?.writeTemplate ?? + (async (path: string, content: string) => { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, content, "utf8"); + }); + + const content = await readTemplate(parsed.template); + + if (parsed.stdout || !parsed.writePath) { + logInfo(content.trimEnd()); + return 0; + } + + const outputPath = resolve(cwd, parsed.writePath); + await writeTemplate( + outputPath, + content.endsWith("\n") ? content : `${content}\n`, + ); + logInfo(`Wrote ${parsed.template} template to ${outputPath}`); + return 0; +} diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 14d4d4d5..c0e4e0b1 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -749,6 +749,22 @@ describe("codex manager cli commands", () => { expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"codexCli"')); }); + it("prints init-config template to stdout by default", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli([ + "auth", + "init-config", + "minimal", + ]); + + expect(exitCode).toBe(0); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('"plugin": ["codex-multi-auth"]'), + ); + }); + it("prints populated account status for auth status", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValueOnce({ From a4848d394dab94fcf1afb5b02180ec76c5ed747b Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 18:39:13 +0800 Subject: [PATCH 133/376] feat: add report explain mode --- docs/reference/commands.md | 1 + lib/codex-manager/commands/report.ts | 23 ++++++++++++++++++++ test/codex-manager-cli.test.ts | 32 ++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index fce08be7..c1be1e76 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -49,6 +49,7 @@ Compatibility aliases are supported: | --- | --- | --- | | `--manual`, `--no-browser` | login | Skip browser launch and use manual callback flow | | `--json` | verify-flagged, forecast, report, fix, doctor, config explain, debug bundle | Print machine-readable output | +| `--explain` | report | Include per-account reasons in text output | | `--live` | forecast, report, fix | Use live probe before decisions/output | | `--dry-run` | verify-flagged, fix, doctor | Preview without writing storage | | `--model ` | forecast, report, fix | Specify model for live probe paths | diff --git a/lib/codex-manager/commands/report.ts b/lib/codex-manager/commands/report.ts index 388666c8..56d68108 100644 --- a/lib/codex-manager/commands/report.ts +++ b/lib/codex-manager/commands/report.ts @@ -17,6 +17,7 @@ import type { TokenFailure, TokenResult } from "../../types.js"; interface ReportCliOptions { live: boolean; json: boolean; + explain: boolean; model: string; outPath?: string; } @@ -70,6 +71,7 @@ function parseReportArgs(args: string[]): ParsedArgsResult { const options: ReportCliOptions = { live: false, json: false, + explain: false, model: "gpt-5-codex", }; @@ -84,6 +86,10 @@ function parseReportArgs(args: string[]): ParsedArgsResult { options.json = true; continue; } + if (arg === "--explain") { + options.explain = true; + continue; + } if (arg === "--model" || arg === "-m") { const value = args[i + 1]; if (!value) return { ok: false, message: "Missing value for --model" }; @@ -335,5 +341,22 @@ export async function runReportCommand( if (report.forecast.probeErrors.length > 0) { logInfo(`Probe notes: ${report.forecast.probeErrors.length}`); } + if (options.explain) { + logInfo(""); + for (const account of report.forecast.accounts) { + logInfo( + `Account ${account.index + 1}: ${account.availability}, ${account.riskLevel} risk (${account.riskScore})`, + ); + if (account.reasons.length > 0) { + logInfo(` Reasons: ${account.reasons.join("; ")}`); + } + if (account.refreshFailure?.message) { + logInfo(` Refresh failure: ${account.refreshFailure.message}`); + } + if (account.liveQuota?.summary) { + logInfo(` Live quota: ${account.liveQuota.summary}`); + } + } + } return 0; } diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 14d4d4d5..dd621cf5 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -749,6 +749,38 @@ describe("codex manager cli commands", () => { expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"codexCli"')); }); + it("prints report explain account rows in text mode", async () => { + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + accounts: [ + { + email: "one@example.com", + refreshToken: "token-1", + addedAt: Date.now(), + lastUsed: Date.now(), + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "failed", + reason: "invalid_grant", + message: "refresh expired", + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli([ + "auth", + "report", + "--explain", + ]); + + expect(exitCode).toBe(0); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Account 1:")); + }); + it("prints populated account status for auth status", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValueOnce({ From 54455c04e439608e133b1e07ce9fa074b4539709 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 19:00:00 +0800 Subject: [PATCH 134/376] refactor: extract backend settings schema --- lib/codex-manager/backend-settings-schema.ts | 350 ++++++++++++++++++ lib/codex-manager/settings-hub.ts | 368 +------------------ 2 files changed, 368 insertions(+), 350 deletions(-) create mode 100644 lib/codex-manager/backend-settings-schema.ts diff --git a/lib/codex-manager/backend-settings-schema.ts b/lib/codex-manager/backend-settings-schema.ts new file mode 100644 index 00000000..1995b913 --- /dev/null +++ b/lib/codex-manager/backend-settings-schema.ts @@ -0,0 +1,350 @@ +import { getDefaultPluginConfig } from "../config.js"; +import type { PluginConfig } from "../types.js"; + +export type BackendToggleSettingKey = + | "liveAccountSync" + | "sessionAffinity" + | "proactiveRefreshGuardian" + | "retryAllAccountsRateLimited" + | "parallelProbing" + | "storageBackupEnabled" + | "preemptiveQuotaEnabled" + | "fastSession" + | "sessionRecovery" + | "autoResume" + | "perProjectAccounts"; + +export type BackendNumberSettingKey = + | "liveAccountSyncDebounceMs" + | "liveAccountSyncPollMs" + | "sessionAffinityTtlMs" + | "sessionAffinityMaxEntries" + | "proactiveRefreshIntervalMs" + | "proactiveRefreshBufferMs" + | "parallelProbingMaxConcurrency" + | "fastSessionMaxInputItems" + | "networkErrorCooldownMs" + | "serverErrorCooldownMs" + | "fetchTimeoutMs" + | "streamStallTimeoutMs" + | "tokenRefreshSkewMs" + | "preemptiveQuotaRemainingPercent5h" + | "preemptiveQuotaRemainingPercent7d" + | "preemptiveQuotaMaxDeferralMs"; + +export type BackendSettingFocusKey = + | BackendToggleSettingKey + | BackendNumberSettingKey + | null; + +export interface BackendToggleSettingOption { + key: BackendToggleSettingKey; + label: string; + description: string; +} + +export interface BackendNumberSettingOption { + key: BackendNumberSettingKey; + label: string; + description: string; + min: number; + max: number; + step: number; + unit: "ms" | "percent" | "count"; +} + +export type BackendCategoryKey = + | "session-sync" + | "rotation-quota" + | "refresh-recovery" + | "performance-timeouts"; + +export interface BackendCategoryOption { + key: BackendCategoryKey; + label: string; + description: string; + toggleKeys: BackendToggleSettingKey[]; + numberKeys: BackendNumberSettingKey[]; +} + +export type BackendCategoryConfigAction = + | { type: "toggle"; key: BackendToggleSettingKey } + | { type: "bump"; key: BackendNumberSettingKey; direction: -1 | 1 } + | { type: "reset-category" } + | { type: "back" }; + +export type BackendSettingsHubAction = + | { type: "open-category"; key: BackendCategoryKey } + | { type: "reset" } + | { type: "save" } + | { type: "cancel" }; + +export const BACKEND_TOGGLE_OPTIONS: BackendToggleSettingOption[] = [ + { + key: "liveAccountSync", + label: "Enable Live Sync", + description: "Keep accounts synced when files change in another window.", + }, + { + key: "sessionAffinity", + label: "Enable Session Affinity", + description: "Try to keep each conversation on the same account.", + }, + { + key: "proactiveRefreshGuardian", + label: "Enable Token Refresh Guard", + description: "Refresh tokens early in the background.", + }, + { + key: "retryAllAccountsRateLimited", + label: "Retry When All Rate-Limited", + description: "If all accounts are limited, wait and try again.", + }, + { + key: "parallelProbing", + label: "Enable Parallel Probing", + description: "Check multiple accounts at the same time.", + }, + { + key: "storageBackupEnabled", + label: "Enable Storage Backups", + description: "Create a backup before account data changes.", + }, + { + key: "preemptiveQuotaEnabled", + label: "Enable Quota Deferral", + description: "Delay requests before limits are fully exhausted.", + }, + { + key: "fastSession", + label: "Enable Fast Session Mode", + description: "Use lighter request handling for faster responses.", + }, + { + key: "sessionRecovery", + label: "Enable Session Recovery", + description: "Restore recoverable sessions after restart.", + }, + { + key: "autoResume", + label: "Enable Auto Resume", + description: "Resume the most recent recoverable session automatically.", + }, + { + key: "perProjectAccounts", + label: "Enable Per-Project Accounts", + description: "Use repo-specific account storage instead of a global pool.", + }, +]; + +export const BACKEND_NUMBER_OPTIONS: BackendNumberSettingOption[] = [ + { + key: "liveAccountSyncDebounceMs", + label: "Live Sync Debounce", + description: "Delay before reacting to file changes.", + min: 50, + max: 60_000, + step: 50, + unit: "ms", + }, + { + key: "liveAccountSyncPollMs", + label: "Live Sync Poll Interval", + description: "Polling fallback interval for external file changes.", + min: 500, + max: 120_000, + step: 500, + unit: "ms", + }, + { + key: "sessionAffinityTtlMs", + label: "Session Affinity TTL", + description: "How long affinity survives without activity.", + min: 1_000, + max: 86_400_000, + step: 60_000, + unit: "ms", + }, + { + key: "sessionAffinityMaxEntries", + label: "Session Affinity Max Entries", + description: "Upper bound for tracked affinity sessions.", + min: 8, + max: 10_000, + step: 8, + unit: "count", + }, + { + key: "proactiveRefreshIntervalMs", + label: "Refresh Guard Interval", + description: "How often the guard scans for refresh work.", + min: 5_000, + max: 3_600_000, + step: 5_000, + unit: "ms", + }, + { + key: "proactiveRefreshBufferMs", + label: "Refresh Guard Buffer", + description: "How early tokens should refresh before expiry.", + min: 30_000, + max: 7_200_000, + step: 30_000, + unit: "ms", + }, + { + key: "parallelProbingMaxConcurrency", + label: "Parallel Probe Concurrency", + description: "Maximum simultaneous account probes.", + min: 1, + max: 32, + step: 1, + unit: "count", + }, + { + key: "fastSessionMaxInputItems", + label: "Fast Session Max Inputs", + description: "Maximum prompt items kept in fast-session mode.", + min: 8, + max: 200, + step: 2, + unit: "count", + }, + { + key: "networkErrorCooldownMs", + label: "Network Error Cooldown", + description: "Cooldown applied after network failures.", + min: 0, + max: 300_000, + step: 1_000, + unit: "ms", + }, + { + key: "serverErrorCooldownMs", + label: "Server Error Cooldown", + description: "Cooldown applied after upstream server failures.", + min: 0, + max: 300_000, + step: 1_000, + unit: "ms", + }, + { + key: "fetchTimeoutMs", + label: "Request Timeout", + description: "Max time to wait for a request.", + min: 1_000, + max: (10 * 60 * 60_000) / 60, + step: 5_000, + unit: "ms", + }, + { + key: "streamStallTimeoutMs", + label: "Stream Stall Timeout", + description: "Max wait before a stuck stream is retried.", + min: 1_000, + max: (10 * 60 * 60_000) / 60, + step: 5_000, + unit: "ms", + }, + { + key: "tokenRefreshSkewMs", + label: "Token Refresh Buffer", + description: "Refresh this long before token expiry.", + min: 0, + max: (10 * 60 * 60_000) / 60, + step: 10_000, + unit: "ms", + }, + { + key: "preemptiveQuotaRemainingPercent5h", + label: "5h Remaining Threshold", + description: "Start delaying when 5h remaining reaches this percent.", + min: 0, + max: 100, + step: 1, + unit: "percent", + }, + { + key: "preemptiveQuotaRemainingPercent7d", + label: "7d Remaining Threshold", + description: "Start delaying when weekly remaining reaches this percent.", + min: 0, + max: 100, + step: 1, + unit: "percent", + }, + { + key: "preemptiveQuotaMaxDeferralMs", + label: "Max Preemptive Deferral", + description: "Maximum time allowed for quota-based delay.", + min: 1_000, + max: 24 * 60 * 60_000, + step: 60_000, + unit: "ms", + }, +]; + +export const BACKEND_CATEGORY_OPTIONS: BackendCategoryOption[] = [ + { + key: "session-sync", + label: "Session & Sync", + description: "Sync and session behavior.", + toggleKeys: [ + "liveAccountSync", + "sessionAffinity", + "perProjectAccounts", + "sessionRecovery", + "autoResume", + ], + numberKeys: [ + "liveAccountSyncDebounceMs", + "liveAccountSyncPollMs", + "sessionAffinityTtlMs", + "sessionAffinityMaxEntries", + ], + }, + { + key: "rotation-quota", + label: "Rotation & Quota", + description: "Quota and retry behavior.", + toggleKeys: ["preemptiveQuotaEnabled", "retryAllAccountsRateLimited"], + numberKeys: [ + "preemptiveQuotaRemainingPercent5h", + "preemptiveQuotaRemainingPercent7d", + "preemptiveQuotaMaxDeferralMs", + ], + }, + { + key: "refresh-recovery", + label: "Refresh & Recovery", + description: "Token refresh and recovery safety.", + toggleKeys: ["storageBackupEnabled"], + numberKeys: ["proactiveRefreshBufferMs", "tokenRefreshSkewMs"], + }, + { + key: "performance-timeouts", + label: "Performance & Timeouts", + description: "Speed, probing, and timeout controls.", + toggleKeys: ["fastSession", "parallelProbing"], + numberKeys: [ + "fastSessionMaxInputItems", + "parallelProbingMaxConcurrency", + "fetchTimeoutMs", + "streamStallTimeoutMs", + "networkErrorCooldownMs", + "serverErrorCooldownMs", + ], + }, +]; + +export const BACKEND_DEFAULTS: PluginConfig = getDefaultPluginConfig(); + +export const BACKEND_TOGGLE_OPTION_BY_KEY = new Map< + BackendToggleSettingKey, + BackendToggleSettingOption +>(BACKEND_TOGGLE_OPTIONS.map((option) => [option.key, option])); + +export const BACKEND_NUMBER_OPTION_BY_KEY = new Map< + BackendNumberSettingKey, + BackendNumberSettingOption +>(BACKEND_NUMBER_OPTIONS.map((option) => [option.key, option])); diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 30417ec3..f2029d07 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -1,11 +1,7 @@ import { promises as fs } from "node:fs"; import { stdin as input, stdout as output } from "node:process"; import { createInterface } from "node:readline/promises"; -import { - getDefaultPluginConfig, - loadPluginConfig, - savePluginConfig, -} from "../config.js"; +import { loadPluginConfig, savePluginConfig } from "../config.js"; import { type DashboardAccentColor, type DashboardAccountSortMode, @@ -31,6 +27,23 @@ import { getUiRuntimeOptions, setUiRuntimeOptions } from "../ui/runtime.js"; import { type MenuItem, type SelectOptions, select } from "../ui/select.js"; import { getUnifiedSettingsPath } from "../unified-settings.js"; import { sleep } from "../utils.js"; +import { + BACKEND_CATEGORY_OPTIONS, + BACKEND_DEFAULTS, + BACKEND_NUMBER_OPTION_BY_KEY, + BACKEND_NUMBER_OPTIONS, + BACKEND_TOGGLE_OPTION_BY_KEY, + BACKEND_TOGGLE_OPTIONS, + type BackendCategoryConfigAction, + type BackendCategoryKey, + type BackendCategoryOption, + type BackendNumberSettingKey, + type BackendNumberSettingOption, + type BackendSettingFocusKey, + type BackendSettingsHubAction, + type BackendToggleSettingKey, + type BackendToggleSettingOption, +} from "./backend-settings-schema.js"; import { promptBehaviorSettingsPanel } from "./behavior-settings-panel.js"; import { promptDashboardDisplayPanel } from "./dashboard-display-panel.js"; import { promptStatuslineSettingsPanel } from "./statusline-settings-panel.js"; @@ -155,84 +168,6 @@ type PreviewFocusKey = | "menuLayoutMode" | null; -type BackendToggleSettingKey = - | "liveAccountSync" - | "sessionAffinity" - | "proactiveRefreshGuardian" - | "retryAllAccountsRateLimited" - | "parallelProbing" - | "storageBackupEnabled" - | "preemptiveQuotaEnabled" - | "fastSession" - | "sessionRecovery" - | "autoResume" - | "perProjectAccounts"; - -type BackendNumberSettingKey = - | "liveAccountSyncDebounceMs" - | "liveAccountSyncPollMs" - | "sessionAffinityTtlMs" - | "sessionAffinityMaxEntries" - | "proactiveRefreshIntervalMs" - | "proactiveRefreshBufferMs" - | "parallelProbingMaxConcurrency" - | "fastSessionMaxInputItems" - | "networkErrorCooldownMs" - | "serverErrorCooldownMs" - | "fetchTimeoutMs" - | "streamStallTimeoutMs" - | "tokenRefreshSkewMs" - | "preemptiveQuotaRemainingPercent5h" - | "preemptiveQuotaRemainingPercent7d" - | "preemptiveQuotaMaxDeferralMs"; - -type BackendSettingFocusKey = - | BackendToggleSettingKey - | BackendNumberSettingKey - | null; - -interface BackendToggleSettingOption { - key: BackendToggleSettingKey; - label: string; - description: string; -} - -interface BackendNumberSettingOption { - key: BackendNumberSettingKey; - label: string; - description: string; - min: number; - max: number; - step: number; - unit: "ms" | "percent" | "count"; -} - -type BackendCategoryKey = - | "session-sync" - | "rotation-quota" - | "refresh-recovery" - | "performance-timeouts"; - -interface BackendCategoryOption { - key: BackendCategoryKey; - label: string; - description: string; - toggleKeys: BackendToggleSettingKey[]; - numberKeys: BackendNumberSettingKey[]; -} - -type BackendCategoryConfigAction = - | { type: "toggle"; key: BackendToggleSettingKey } - | { type: "bump"; key: BackendNumberSettingKey; direction: -1 | 1 } - | { type: "reset-category" } - | { type: "back" }; - -type BackendSettingsHubAction = - | { type: "open-category"; key: BackendCategoryKey } - | { type: "reset" } - | { type: "save" } - | { type: "cancel" }; - type SettingsHubAction = | { type: "account-list" } | { type: "summary-fields" } @@ -288,273 +223,6 @@ function mapExperimentalStatusHotkey( return raw.toLowerCase() === "q" ? { type: "back" } : undefined; } -const BACKEND_TOGGLE_OPTIONS: BackendToggleSettingOption[] = [ - { - key: "liveAccountSync", - label: "Enable Live Sync", - description: "Keep accounts synced when files change in another window.", - }, - { - key: "sessionAffinity", - label: "Enable Session Affinity", - description: "Try to keep each conversation on the same account.", - }, - { - key: "proactiveRefreshGuardian", - label: "Enable Token Refresh Guard", - description: "Refresh tokens early in the background.", - }, - { - key: "retryAllAccountsRateLimited", - label: "Retry When All Rate-Limited", - description: "If all accounts are limited, wait and try again.", - }, - { - key: "parallelProbing", - label: "Enable Parallel Probing", - description: "Check multiple accounts at the same time.", - }, - { - key: "storageBackupEnabled", - label: "Enable Storage Backups", - description: "Create a backup before account data changes.", - }, - { - key: "preemptiveQuotaEnabled", - label: "Enable Quota Deferral", - description: "Delay requests before limits are fully exhausted.", - }, - { - key: "fastSession", - label: "Enable Fast Session Mode", - description: "Use lighter request handling for faster responses.", - }, - { - key: "sessionRecovery", - label: "Enable Session Recovery", - description: "Restore recoverable sessions after restart.", - }, - { - key: "autoResume", - label: "Enable Auto Resume", - description: "Automatically continue sessions when possible.", - }, - { - key: "perProjectAccounts", - label: "Enable Per-Project Accounts", - description: "Keep separate account lists for each project.", - }, -]; - -const BACKEND_NUMBER_OPTIONS: BackendNumberSettingOption[] = [ - { - key: "liveAccountSyncDebounceMs", - label: "Live Sync Debounce", - description: "Wait this long before applying sync file changes.", - min: 50, - max: 10_000, - step: 50, - unit: "ms", - }, - { - key: "liveAccountSyncPollMs", - label: "Live Sync Poll", - description: "How often to check files for account updates.", - min: 500, - max: 60_000, - step: 500, - unit: "ms", - }, - { - key: "sessionAffinityTtlMs", - label: "Session Affinity TTL", - description: "How long conversation-to-account mapping is kept.", - min: 1_000, - max: 24 * 60 * 60_000, - step: 60_000, - unit: "ms", - }, - { - key: "sessionAffinityMaxEntries", - label: "Session Affinity Max Entries", - description: "Maximum stored conversation mappings.", - min: 8, - max: 4_096, - step: 32, - unit: "count", - }, - { - key: "proactiveRefreshIntervalMs", - label: "Refresh Guard Interval", - description: "How often to scan for tokens near expiry.", - min: 5_000, - max: 10 * 60_000, - step: 5_000, - unit: "ms", - }, - { - key: "proactiveRefreshBufferMs", - label: "Refresh Guard Buffer", - description: "How early to refresh before expiry.", - min: 30_000, - max: 10 * 60_000, - step: 30_000, - unit: "ms", - }, - { - key: "parallelProbingMaxConcurrency", - label: "Parallel Probe Concurrency", - description: "Maximum checks running at once.", - min: 1, - max: 5, - step: 1, - unit: "count", - }, - { - key: "fastSessionMaxInputItems", - label: "Fast Session Max Inputs", - description: "Max number of input items kept in fast mode.", - min: 8, - max: 200, - step: 2, - unit: "count", - }, - { - key: "networkErrorCooldownMs", - label: "Network Error Cooldown", - description: "Wait time after network errors before retry.", - min: 0, - max: 120_000, - step: 500, - unit: "ms", - }, - { - key: "serverErrorCooldownMs", - label: "Server Error Cooldown", - description: "Wait time after server errors before retry.", - min: 0, - max: 120_000, - step: 500, - unit: "ms", - }, - { - key: "fetchTimeoutMs", - label: "Request Timeout", - description: "Max time to wait for a request.", - min: 1_000, - max: 10 * 60_000, - step: 5_000, - unit: "ms", - }, - { - key: "streamStallTimeoutMs", - label: "Stream Stall Timeout", - description: "Max wait before a stuck stream is retried.", - min: 1_000, - max: 10 * 60_000, - step: 5_000, - unit: "ms", - }, - { - key: "tokenRefreshSkewMs", - label: "Token Refresh Buffer", - description: "Refresh this long before token expiry.", - min: 0, - max: 10 * 60_000, - step: 10_000, - unit: "ms", - }, - { - key: "preemptiveQuotaRemainingPercent5h", - label: "5h Remaining Threshold", - description: "Start delaying when 5h remaining reaches this percent.", - min: 0, - max: 100, - step: 1, - unit: "percent", - }, - { - key: "preemptiveQuotaRemainingPercent7d", - label: "7d Remaining Threshold", - description: "Start delaying when weekly remaining reaches this percent.", - min: 0, - max: 100, - step: 1, - unit: "percent", - }, - { - key: "preemptiveQuotaMaxDeferralMs", - label: "Max Preemptive Deferral", - description: "Maximum time allowed for quota-based delay.", - min: 1_000, - max: 24 * 60 * 60_000, - step: 60_000, - unit: "ms", - }, -]; - -const BACKEND_DEFAULTS = getDefaultPluginConfig(); -const BACKEND_TOGGLE_OPTION_BY_KEY = new Map< - BackendToggleSettingKey, - BackendToggleSettingOption ->(BACKEND_TOGGLE_OPTIONS.map((option) => [option.key, option])); -const BACKEND_NUMBER_OPTION_BY_KEY = new Map< - BackendNumberSettingKey, - BackendNumberSettingOption ->(BACKEND_NUMBER_OPTIONS.map((option) => [option.key, option])); -const BACKEND_CATEGORY_OPTIONS: BackendCategoryOption[] = [ - { - key: "session-sync", - label: "Session & Sync", - description: "Sync and session behavior.", - toggleKeys: [ - "liveAccountSync", - "sessionAffinity", - "perProjectAccounts", - "sessionRecovery", - "autoResume", - ], - numberKeys: [ - "liveAccountSyncDebounceMs", - "liveAccountSyncPollMs", - "sessionAffinityTtlMs", - "sessionAffinityMaxEntries", - ], - }, - { - key: "rotation-quota", - label: "Rotation & Quota", - description: "Quota and retry behavior.", - toggleKeys: ["preemptiveQuotaEnabled", "retryAllAccountsRateLimited"], - numberKeys: [ - "preemptiveQuotaRemainingPercent5h", - "preemptiveQuotaRemainingPercent7d", - "preemptiveQuotaMaxDeferralMs", - ], - }, - { - key: "refresh-recovery", - label: "Refresh & Recovery", - description: "Token refresh and recovery safety.", - toggleKeys: ["storageBackupEnabled"], - numberKeys: ["proactiveRefreshBufferMs", "tokenRefreshSkewMs"], - }, - { - key: "performance-timeouts", - label: "Performance & Timeouts", - description: "Speed, probing, and timeout controls.", - toggleKeys: ["fastSession", "parallelProbing"], - numberKeys: [ - "fastSessionMaxInputItems", - "parallelProbingMaxConcurrency", - "fetchTimeoutMs", - "streamStallTimeoutMs", - "networkErrorCooldownMs", - "serverErrorCooldownMs", - ], - }, -]; - type DashboardSettingKey = keyof DashboardDisplaySettings; const RETRYABLE_SETTINGS_WRITE_CODES = new Set([ From f186246b66e7012461d5c07bec1955967f34e54a Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 19:21:03 +0800 Subject: [PATCH 135/376] refactor: extract backend settings schema --- lib/codex-manager/settings-hub.ts | 54 ++++--------------------------- 1 file changed, 7 insertions(+), 47 deletions(-) diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index f2029d07..615c24d9 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -24,7 +24,7 @@ import type { PluginConfig } from "../types.js"; import { ANSI } from "../ui/ansi.js"; import { UI_COPY } from "../ui/copy.js"; import { getUiRuntimeOptions, setUiRuntimeOptions } from "../ui/runtime.js"; -import { type MenuItem, type SelectOptions, select } from "../ui/select.js"; +import { type MenuItem, select } from "../ui/select.js"; import { getUnifiedSettingsPath } from "../unified-settings.js"; import { sleep } from "../utils.js"; import { @@ -46,6 +46,12 @@ import { } from "./backend-settings-schema.js"; import { promptBehaviorSettingsPanel } from "./behavior-settings-panel.js"; import { promptDashboardDisplayPanel } from "./dashboard-display-panel.js"; +import { + type ExperimentalSettingsAction, + getExperimentalSelectOptions, + mapExperimentalMenuHotkey, + mapExperimentalStatusHotkey, +} from "./experimental-settings-schema.js"; import { promptStatuslineSettingsPanel } from "./statusline-settings-panel.js"; import { promptThemeSettingsPanel } from "./theme-settings-panel.js"; @@ -177,52 +183,6 @@ type SettingsHubAction = | { type: "backend" } | { type: "back" }; -type ExperimentalSettingsAction = - | { type: "sync" } - | { type: "backup" } - | { type: "toggle-refresh-guardian" } - | { type: "decrease-refresh-interval" } - | { type: "increase-refresh-interval" } - | { type: "apply" } - | { type: "save" } - | { type: "back" }; - -function getExperimentalSelectOptions( - ui: ReturnType, - help: string, - onInput?: SelectOptions["onInput"], -): SelectOptions { - return { - message: UI_COPY.settings.experimentalTitle, - subtitle: UI_COPY.settings.experimentalSubtitle, - help, - clearScreen: true, - theme: ui.theme, - selectedEmphasis: "minimal", - onInput, - }; -} - -function mapExperimentalMenuHotkey( - raw: string, -): ExperimentalSettingsAction | undefined { - if (raw === "1") return { type: "sync" }; - if (raw === "2") return { type: "backup" }; - if (raw === "3") return { type: "toggle-refresh-guardian" }; - if (raw === "[" || raw === "-") return { type: "decrease-refresh-interval" }; - if (raw === "]" || raw === "+") return { type: "increase-refresh-interval" }; - const lower = raw.toLowerCase(); - if (lower === "q") return { type: "back" }; - if (lower === "s") return { type: "save" }; - return undefined; -} - -function mapExperimentalStatusHotkey( - raw: string, -): ExperimentalSettingsAction | undefined { - return raw.toLowerCase() === "q" ? { type: "back" } : undefined; -} - type DashboardSettingKey = keyof DashboardDisplaySettings; const RETRYABLE_SETTINGS_WRITE_CODES = new Set([ From 4c93b44076db072f64da57f665b3d81e738f229e Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 19:37:26 +0800 Subject: [PATCH 136/376] refactor: extract experimental settings schema --- .../experimental-settings-schema.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 lib/codex-manager/experimental-settings-schema.ts diff --git a/lib/codex-manager/experimental-settings-schema.ts b/lib/codex-manager/experimental-settings-schema.ts new file mode 100644 index 00000000..3bc5b846 --- /dev/null +++ b/lib/codex-manager/experimental-settings-schema.ts @@ -0,0 +1,49 @@ +import { UI_COPY } from "../ui/copy.js"; +import type { getUiRuntimeOptions } from "../ui/runtime.js"; +import type { SelectOptions } from "../ui/select.js"; + +export type ExperimentalSettingsAction = + | { type: "sync" } + | { type: "backup" } + | { type: "toggle-refresh-guardian" } + | { type: "decrease-refresh-interval" } + | { type: "increase-refresh-interval" } + | { type: "apply" } + | { type: "save" } + | { type: "back" }; + +export function getExperimentalSelectOptions( + ui: ReturnType, + help: string, + onInput?: SelectOptions["onInput"], +): SelectOptions { + return { + message: UI_COPY.settings.experimentalTitle, + subtitle: UI_COPY.settings.experimentalSubtitle, + help, + clearScreen: true, + theme: ui.theme, + selectedEmphasis: "minimal", + onInput, + }; +} + +export function mapExperimentalMenuHotkey( + raw: string, +): ExperimentalSettingsAction | undefined { + if (raw === "1") return { type: "sync" }; + if (raw === "2") return { type: "backup" }; + if (raw === "3") return { type: "toggle-refresh-guardian" }; + if (raw === "[" || raw === "-") return { type: "decrease-refresh-interval" }; + if (raw === "]" || raw === "+") return { type: "increase-refresh-interval" }; + const lower = raw.toLowerCase(); + if (lower === "q") return { type: "back" }; + if (lower === "s") return { type: "save" }; + return undefined; +} + +export function mapExperimentalStatusHotkey( + raw: string, +): ExperimentalSettingsAction | undefined { + return raw.toLowerCase() === "q" ? { type: "back" } : undefined; +} From 358398d004402eb7af3cce0a58e43c67aab372b5 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 19:59:57 +0800 Subject: [PATCH 137/376] ci: typecheck scripts in pr ci --- .github/workflows/pr-ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 591fcf30..f5ba8e5a 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -56,6 +56,9 @@ jobs: - name: Verify vendor provenance run: npm run vendor:verify + - name: Typecheck scripts + run: npm run typecheck:scripts + node22-smoke: name: Node 22 Smoke runs-on: ubuntu-latest From 753ca5c5548d0c0722cd27086184071a739af846 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 20:02:24 +0800 Subject: [PATCH 138/376] chore: add vendor manifest refresh script --- package.json | 1 + scripts/update-vendor-provenance.mjs | 65 +++++++++++++++++ vendor/provenance.json | 102 +++++++++++++-------------- 3 files changed, 117 insertions(+), 51 deletions(-) create mode 100644 scripts/update-vendor-provenance.mjs diff --git a/package.json b/package.json index 35e1fcd8..38cb2a23 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "audit:dev:allowlist": "node scripts/audit-dev-allowlist.js", "audit:ci": "npm run audit:prod && npm run audit:dev:allowlist", "vendor:verify": "node scripts/verify-vendor-provenance.mjs", + "vendor:update-manifest": "node scripts/update-vendor-provenance.mjs", "prepublishOnly": "npm run build", "prepare": "husky" }, diff --git a/scripts/update-vendor-provenance.mjs b/scripts/update-vendor-provenance.mjs new file mode 100644 index 00000000..52ddd534 --- /dev/null +++ b/scripts/update-vendor-provenance.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; + +const components = [ + { + name: "@codex-ai/plugin", + root: "vendor/codex-ai-plugin", + source: "vendored dist shim", + files: [ + "vendor/codex-ai-plugin/package.json", + "vendor/codex-ai-plugin/dist/index.js", + "vendor/codex-ai-plugin/dist/index.d.ts", + "vendor/codex-ai-plugin/dist/tool.js", + "vendor/codex-ai-plugin/dist/tool.d.ts", + ], + }, + { + name: "@codex-ai/sdk", + root: "vendor/codex-ai-sdk", + source: "vendored dist shim", + files: [ + "vendor/codex-ai-sdk/package.json", + "vendor/codex-ai-sdk/dist/index.js", + "vendor/codex-ai-sdk/dist/index.d.ts", + ], + }, +]; + +async function hashFile(path) { + const content = await readFile(path); + return createHash("sha256").update(content).digest("hex"); +} + +const manifest = { + generatedAt: new Date().toISOString().slice(0, 10), + components: [], +}; + +for (const component of components) { + const packageJson = JSON.parse( + await readFile(`${component.root}/package.json`, "utf8"), + ); + manifest.components.push({ + name: component.name, + version: packageJson.version, + source: component.source, + root: component.root, + files: await Promise.all( + component.files.map(async (path) => ({ + path, + sha256: await hashFile(path), + })), + ), + }); +} + +await writeFile( + "vendor/provenance.json", + `${JSON.stringify(manifest, null, 2)}\n`, + "utf8", +); +console.log( + `Updated vendor/provenance.json for ${manifest.components.length} component(s)`, +); diff --git a/vendor/provenance.json b/vendor/provenance.json index 0cbb8ca6..11bc707d 100644 --- a/vendor/provenance.json +++ b/vendor/provenance.json @@ -1,53 +1,53 @@ { - "generatedAt": "2026-03-21", - "components": [ - { - "name": "@codex-ai/plugin", - "version": "1.2.10-codex.1", - "source": "vendored dist shim", - "root": "vendor/codex-ai-plugin", - "files": [ - { - "path": "vendor/codex-ai-plugin/package.json", - "sha256": "b4dda9e535af9546e647f7651e1da57db1dae8af40d14eb4d97bb0ba972b8bc8" - }, - { - "path": "vendor/codex-ai-plugin/dist/index.js", - "sha256": "aee9a2454b1778ff0af7c648a172074fe61446e21e76bac0e354ef1d12c0dc22" - }, - { - "path": "vendor/codex-ai-plugin/dist/index.d.ts", - "sha256": "9cd22362749c262f7f87e05ea981659a14f0e03450aeb6ab4e7ad39693662c10" - }, - { - "path": "vendor/codex-ai-plugin/dist/tool.js", - "sha256": "79b5301d1a200b5614d1b38420b62b6080ce77999be2bc25fc1c5239abe9f197" - }, - { - "path": "vendor/codex-ai-plugin/dist/tool.d.ts", - "sha256": "c04a703b191beeadb97293d059a93bad4fd445afacc4849fcf25346b1456f160" - } - ] - }, - { - "name": "@codex-ai/sdk", - "version": "1.2.10-codex.1", - "source": "vendored dist shim", - "root": "vendor/codex-ai-sdk", - "files": [ - { - "path": "vendor/codex-ai-sdk/package.json", - "sha256": "8addd64386f878ba30f3ab12e2b86bdcd1d8eeb24cb2ad692be7de73587211a4" - }, - { - "path": "vendor/codex-ai-sdk/dist/index.js", - "sha256": "aee9a2454b1778ff0af7c648a172074fe61446e21e76bac0e354ef1d12c0dc22" - }, - { - "path": "vendor/codex-ai-sdk/dist/index.d.ts", - "sha256": "41bffc0c5a2d44f83ab4d8bd3618c35bd0cd0bf19534185943e26d23a0e41b23" - } - ] - } - ] + "generatedAt": "2026-03-21", + "components": [ + { + "name": "@codex-ai/plugin", + "version": "1.2.10-codex.1", + "source": "vendored dist shim", + "root": "vendor/codex-ai-plugin", + "files": [ + { + "path": "vendor/codex-ai-plugin/package.json", + "sha256": "b4dda9e535af9546e647f7651e1da57db1dae8af40d14eb4d97bb0ba972b8bc8" + }, + { + "path": "vendor/codex-ai-plugin/dist/index.js", + "sha256": "aee9a2454b1778ff0af7c648a172074fe61446e21e76bac0e354ef1d12c0dc22" + }, + { + "path": "vendor/codex-ai-plugin/dist/index.d.ts", + "sha256": "9cd22362749c262f7f87e05ea981659a14f0e03450aeb6ab4e7ad39693662c10" + }, + { + "path": "vendor/codex-ai-plugin/dist/tool.js", + "sha256": "79b5301d1a200b5614d1b38420b62b6080ce77999be2bc25fc1c5239abe9f197" + }, + { + "path": "vendor/codex-ai-plugin/dist/tool.d.ts", + "sha256": "c04a703b191beeadb97293d059a93bad4fd445afacc4849fcf25346b1456f160" + } + ] + }, + { + "name": "@codex-ai/sdk", + "version": "1.2.10-codex.1", + "source": "vendored dist shim", + "root": "vendor/codex-ai-sdk", + "files": [ + { + "path": "vendor/codex-ai-sdk/package.json", + "sha256": "8addd64386f878ba30f3ab12e2b86bdcd1d8eeb24cb2ad692be7de73587211a4" + }, + { + "path": "vendor/codex-ai-sdk/dist/index.js", + "sha256": "aee9a2454b1778ff0af7c648a172074fe61446e21e76bac0e354ef1d12c0dc22" + }, + { + "path": "vendor/codex-ai-sdk/dist/index.d.ts", + "sha256": "41bffc0c5a2d44f83ab4d8bd3618c35bd0cd0bf19534185943e26d23a0e41b23" + } + ] + } + ] } From 564e8d0024294701d4ee906cf75281187f34967d Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 20:11:35 +0800 Subject: [PATCH 139/376] refactor: route runtime account check helper --- index.ts | 88 +++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 56 insertions(+), 32 deletions(-) diff --git a/index.ts b/index.ts index f9f87d3c..cd6bff47 100644 --- a/index.ts +++ b/index.ts @@ -2357,36 +2357,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { getUnsupportedCodexModelInfo, }); - const runAccountCheck = async (deepProbe: boolean): Promise => - runRuntimeAccountCheck(deepProbe, { - hydrateEmails, - loadAccounts, - createEmptyStorage: () => ({ - version: 3 as const, - accounts: [], - activeIndex: 0, - activeIndexByFamily: {}, - }), - loadFlaggedAccounts, - createAccountCheckWorkingState, - lookupCodexCliTokensByEmail, - extractAccountId, - shouldUpdateAccountIdFromToken, - sanitizeEmail, - extractAccountEmail, - queuedRefresh, - isRuntimeFlaggableFailure, - fetchCodexQuotaSnapshot, - resolveRequestAccountId, - formatCodexQuotaLine, - clampRuntimeActiveIndices, - MODEL_FAMILIES, - saveAccounts, - invalidateAccountManagerCache, - saveFlaggedAccounts, - showLine: (message) => console.log(message), - }); - if (!explicitLoginMode) { while (true) { const loadedStorage = await hydrateEmails(await loadAccounts()); @@ -2444,11 +2414,65 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } if (menuResult.mode === "check") { - await runAccountCheck(false); + await runRuntimeAccountCheck(false, { + hydrateEmails, + loadAccounts, + createEmptyStorage: () => ({ + version: 3 as const, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }), + loadFlaggedAccounts, + createAccountCheckWorkingState, + lookupCodexCliTokensByEmail, + extractAccountId, + shouldUpdateAccountIdFromToken, + sanitizeEmail, + extractAccountEmail, + queuedRefresh, + isRuntimeFlaggableFailure, + fetchCodexQuotaSnapshot, + resolveRequestAccountId, + formatCodexQuotaLine, + clampRuntimeActiveIndices, + MODEL_FAMILIES, + saveAccounts, + invalidateAccountManagerCache, + saveFlaggedAccounts, + showLine: (message) => console.log(message), + }); continue; } if (menuResult.mode === "deep-check") { - await runAccountCheck(true); + await runRuntimeAccountCheck(true, { + hydrateEmails, + loadAccounts, + createEmptyStorage: () => ({ + version: 3 as const, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }), + loadFlaggedAccounts, + createAccountCheckWorkingState, + lookupCodexCliTokensByEmail, + extractAccountId, + shouldUpdateAccountIdFromToken, + sanitizeEmail, + extractAccountEmail, + queuedRefresh, + isRuntimeFlaggableFailure, + fetchCodexQuotaSnapshot, + resolveRequestAccountId, + formatCodexQuotaLine, + clampRuntimeActiveIndices, + MODEL_FAMILIES, + saveAccounts, + invalidateAccountManagerCache, + saveFlaggedAccounts, + showLine: (message) => console.log(message), + }); continue; } if (menuResult.mode === "verify-flagged") { From 620ea9a0dd301e2756851e53c18e5def6eff8a1c Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 20:17:45 +0800 Subject: [PATCH 140/376] refactor: route runtime auth facade directly --- index.ts | 28 +++++++++------------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/index.ts b/index.ts index 6ebaaecb..1504da09 100644 --- a/index.ts +++ b/index.ts @@ -254,12 +254,7 @@ import { createHashlineEditTool, createHashlineReadTool, } from "./lib/tools/hashline-tools.js"; -import type { - OAuthAuthDetails, - RequestBody, - TokenResult, - UserConfig, -} from "./lib/types.js"; +import type { OAuthAuthDetails, RequestBody, UserConfig } from "./lib/types.js"; import { formatUiBadge, formatUiHeader, @@ -325,18 +320,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const runtimeMetrics: RuntimeMetrics = createRuntimeMetrics(); - const runOAuthFlow = async ( - forceNewLogin: boolean = false, - ): Promise => - runRuntimeOAuthFlow(forceNewLogin, { - runOAuthBrowserFlow, - manualModeLabel: AUTH_LABELS.OAUTH_MANUAL, - logInfo, - logDebug, - logWarn, - pluginName: PLUGIN_NAME, - }); - const persistAccounts = createPersistAccounts({ persistAccountPool, withAccountStorageTransaction, @@ -2890,7 +2873,14 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { logInfo(`=== OpenAI OAuth (Account ${accounts.length + 1}) ===`); const forceNewLogin = accounts.length > 0 || refreshAccountIndex !== undefined; - const result = await runOAuthFlow(forceNewLogin); + const result = await runRuntimeOAuthFlow(forceNewLogin, { + runOAuthBrowserFlow, + manualModeLabel: AUTH_LABELS.OAUTH_MANUAL, + logInfo, + logDebug, + logWarn, + pluginName: PLUGIN_NAME, + }); let resolved: TokenSuccessWithAccount | null = null; if (result.type === "success") { From 937bf05a23ed0f86037ce4c3b7f6f4395b83d11b Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 20:20:01 +0800 Subject: [PATCH 141/376] refactor: route runtime account scope helper --- index.ts | 67 +++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 45 insertions(+), 22 deletions(-) diff --git a/index.ts b/index.ts index 4ae89643..4c5f5f5b 100644 --- a/index.ts +++ b/index.ts @@ -437,25 +437,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } }; - const applyStorageScope = ( - pluginConfig: ReturnType, - ): void => - applyAccountStorageScope(pluginConfig, { - getPerProjectAccounts, - getStorageBackupEnabled, - isCodexCliSyncEnabled, - setStorageBackupEnabled, - setStoragePath, - getCwd: () => process.cwd(), - warnPerProjectSyncConflict: () => { - if (perProjectStorageWarningShown) return; - perProjectStorageWarningShown = true; - logWarn( - `[${PLUGIN_NAME}] CODEX_AUTH_PER_PROJECT_ACCOUNTS is ignored while Codex CLI sync is enabled. Using global account storage.`, - ); - }, - }); - const ensureLiveAccountSync = async ( pluginConfig: ReturnType, authFallback?: OAuthAuthDetails, @@ -666,7 +647,21 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const auth = await getAuth(); const pluginConfig = loadPluginConfig(); applyUiRuntimeFromConfig(pluginConfig); - applyStorageScope(pluginConfig); + applyAccountStorageScope(pluginConfig, { + getPerProjectAccounts, + getStorageBackupEnabled, + isCodexCliSyncEnabled, + setStorageBackupEnabled, + setStoragePath, + getCwd: () => process.cwd(), + warnPerProjectSyncConflict: () => { + if (perProjectStorageWarningShown) return; + perProjectStorageWarningShown = true; + logWarn( + `[${PLUGIN_NAME}] CODEX_AUTH_PER_PROJECT_ACCOUNTS is ignored while Codex CLI sync is enabled. Using global account storage.`, + ); + }, + }); ensureSessionAffinity(pluginConfig); ensureRefreshGuardian(pluginConfig); applyPreemptiveQuotaSettings(pluginConfig); @@ -2530,7 +2525,21 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { authorize: async (inputs?: Record) => { const authPluginConfig = loadPluginConfig(); applyUiRuntimeFromConfig(authPluginConfig); - applyStorageScope(authPluginConfig); + applyAccountStorageScope(authPluginConfig, { + getPerProjectAccounts, + getStorageBackupEnabled, + isCodexCliSyncEnabled, + setStorageBackupEnabled, + setStoragePath, + getCwd: () => process.cwd(), + warnPerProjectSyncConflict: () => { + if (perProjectStorageWarningShown) return; + perProjectStorageWarningShown = true; + logWarn( + `[${PLUGIN_NAME}] CODEX_AUTH_PER_PROJECT_ACCOUNTS is ignored while Codex CLI sync is enabled. Using global account storage.`, + ); + }, + }); const accounts: TokenSuccessWithAccount[] = []; const noBrowser = @@ -3719,7 +3728,21 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { // Must happen BEFORE persistAccountPool to ensure correct storage location const manualPluginConfig = loadPluginConfig(); applyUiRuntimeFromConfig(manualPluginConfig); - applyStorageScope(manualPluginConfig); + applyAccountStorageScope(manualPluginConfig, { + getPerProjectAccounts, + getStorageBackupEnabled, + isCodexCliSyncEnabled, + setStorageBackupEnabled, + setStoragePath, + getCwd: () => process.cwd(), + warnPerProjectSyncConflict: () => { + if (perProjectStorageWarningShown) return; + perProjectStorageWarningShown = true; + logWarn( + `[${PLUGIN_NAME}] CODEX_AUTH_PER_PROJECT_ACCOUNTS is ignored while Codex CLI sync is enabled. Using global account storage.`, + ); + }, + }); const { pkce, state, url } = await createAuthorizationFlow(); return buildManualOAuthFlow(pkce, url, state, { From 4c37b475cf4cf13fbe8423cd5cece1a4bd051ac9 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 20:22:16 +0800 Subject: [PATCH 142/376] refactor: route runtime session affinity helper --- index.ts | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/index.ts b/index.ts index 3ff9fc6a..7f86ae45 100644 --- a/index.ts +++ b/index.ts @@ -503,21 +503,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { refreshGuardianConfigKey = ensured.configKey; }; - const ensureSessionAffinity = ( - pluginConfig: ReturnType, - ): void => { - const ensured = ensureRuntimeSessionAffinity({ - pluginConfig, - getSessionAffinity, - currentStore: sessionAffinityStore, - currentConfigKey: sessionAffinityConfigKey, - getSessionAffinityTtlMs, - getSessionAffinityMaxEntries, - }); - sessionAffinityStore = ensured.store; - sessionAffinityConfigKey = ensured.configKey; - }; - const applyPreemptiveQuotaSettings = ( pluginConfig: ReturnType, ): void => { @@ -626,7 +611,18 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const pluginConfig = loadPluginConfig(); applyUiRuntimeFromConfig(pluginConfig); applyStorageScope(pluginConfig); - ensureSessionAffinity(pluginConfig); + { + const ensured = ensureRuntimeSessionAffinity({ + pluginConfig, + getSessionAffinity, + currentStore: sessionAffinityStore, + currentConfigKey: sessionAffinityConfigKey, + getSessionAffinityTtlMs, + getSessionAffinityMaxEntries, + }); + sessionAffinityStore = ensured.store; + sessionAffinityConfigKey = ensured.configKey; + } ensureRefreshGuardian(pluginConfig); applyPreemptiveQuotaSettings(pluginConfig); From 0bde5a176d7772d7a0005f4173f35ac139fab0bb Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 20:26:20 +0800 Subject: [PATCH 143/376] refactor: route runtime refresh guardian helper --- index.ts | 40 ++++++++++++++++++---------------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/index.ts b/index.ts index 53585af7..643d2b3d 100644 --- a/index.ts +++ b/index.ts @@ -481,27 +481,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { liveAccountSyncPath = ensured.path; }; - const ensureRefreshGuardian = ( - pluginConfig: ReturnType, - ): void => { - const ensured = ensureRuntimeRefreshGuardian({ - pluginConfig, - getProactiveRefreshGuardian, - currentGuardian: refreshGuardian, - currentConfigKey: refreshGuardianConfigKey, - getProactiveRefreshIntervalMs, - getProactiveRefreshBufferMs, - createGuardian: ({ intervalMs, bufferMs }) => - new RefreshGuardian(() => cachedAccountManager, { - intervalMs, - bufferMs, - }), - registerCleanup, - }); - refreshGuardian = ensured.guardian; - refreshGuardianConfigKey = ensured.configKey; - }; - const ensureSessionAffinity = ( pluginConfig: ReturnType, ): void => { @@ -628,7 +607,24 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { applyUiRuntimeFromConfig(pluginConfig); applyStorageScope(pluginConfig); ensureSessionAffinity(pluginConfig); - ensureRefreshGuardian(pluginConfig); + { + const ensured = ensureRuntimeRefreshGuardian({ + pluginConfig, + getProactiveRefreshGuardian, + currentGuardian: refreshGuardian, + currentConfigKey: refreshGuardianConfigKey, + getProactiveRefreshIntervalMs, + getProactiveRefreshBufferMs, + createGuardian: ({ intervalMs, bufferMs }) => + new RefreshGuardian(() => cachedAccountManager, { + intervalMs, + bufferMs, + }), + registerCleanup, + }); + refreshGuardian = ensured.guardian; + refreshGuardianConfigKey = ensured.configKey; + } applyPreemptiveQuotaSettings(pluginConfig); // Only handle OAuth auth type, skip API key auth From 13736cecdb6e853137f68ad44f34a96821fddf41 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 20:28:48 +0800 Subject: [PATCH 144/376] refactor: route runtime preemptive quota helper --- index.ts | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/index.ts b/index.ts index b844ac4a..2b63c9ec 100644 --- a/index.ts +++ b/index.ts @@ -519,18 +519,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { sessionAffinityConfigKey = ensured.configKey; }; - const applyPreemptiveQuotaSettings = ( - pluginConfig: ReturnType, - ): void => { - applyRuntimePreemptiveQuotaSettings(pluginConfig, { - configure: (options) => preemptiveQuotaScheduler.configure(options), - getPreemptiveQuotaEnabled, - getPreemptiveQuotaRemainingPercent5h, - getPreemptiveQuotaRemainingPercent7d, - getPreemptiveQuotaMaxDeferralMs, - }); - }; - // Event handler for session recovery and account selection const eventHandler = async (input: { event: { type: string; properties?: unknown }; @@ -628,7 +616,13 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { applyStorageScope(pluginConfig); ensureSessionAffinity(pluginConfig); ensureRefreshGuardian(pluginConfig); - applyPreemptiveQuotaSettings(pluginConfig); + applyRuntimePreemptiveQuotaSettings(pluginConfig, { + configure: (options) => preemptiveQuotaScheduler.configure(options), + getPreemptiveQuotaEnabled, + getPreemptiveQuotaRemainingPercent5h, + getPreemptiveQuotaRemainingPercent7d, + getPreemptiveQuotaMaxDeferralMs, + }); // Only handle OAuth auth type, skip API key auth if (auth.type !== "oauth") { From 754a671ec79e2a125c8b339bc0778529aa57480b Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 20:31:33 +0800 Subject: [PATCH 145/376] refactor: route runtime ui resolver directly --- index.ts | 57 +++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 17 deletions(-) diff --git a/index.ts b/index.ts index 0a45563f..32db5332 100644 --- a/index.ts +++ b/index.ts @@ -410,13 +410,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }); }; - const resolveUiRuntime = (): UiRuntimeOptions => { - return resolveRuntimeUiOptions({ - loadPluginConfig, - applyUiRuntimeFromConfig, - }); - }; - const getStatusMarker = ( ui: UiRuntimeOptions, status: "ok" | "warning" | "error", @@ -570,7 +563,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }; // Initialize runtime UI settings once on plugin load; auth/tools refresh this dynamically. - resolveUiRuntime(); + resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); return { event: eventHandler, @@ -3689,7 +3685,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { "List all Codex OAuth accounts and the current active index.", args: {}, async execute() { - const ui = resolveUiRuntime(); + const ui = resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); const storage = await loadAccounts(); const storePath = getStoragePath(); @@ -3825,7 +3824,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ), }, async execute({ index }) { - const ui = resolveUiRuntime(); + const ui = resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); const storage = await loadAccounts(); if (!storage || storage.accounts.length === 0) { if (ui.v2Enabled) { @@ -3921,7 +3923,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { description: "Show detailed status of Codex accounts and rate limits.", args: {}, async execute() { - const ui = resolveUiRuntime(); + const ui = resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); const storage = await loadAccounts(); if (!storage || storage.accounts.length === 0) { if (ui.v2Enabled) { @@ -4092,7 +4097,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { "Show runtime request metrics for this plugin process.", args: {}, execute() { - const ui = resolveUiRuntime(); + const ui = resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); const now = Date.now(); const uptimeMs = Math.max(0, now - runtimeMetrics.startedAt); const total = runtimeMetrics.totalRequests; @@ -4294,7 +4302,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { "Check health of all Codex accounts by validating refresh tokens.", args: {}, async execute() { - const ui = resolveUiRuntime(); + const ui = resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); const storage = await loadAccounts(); if (!storage || storage.accounts.length === 0) { if (ui.v2Enabled) { @@ -4372,7 +4383,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ), }, async execute({ index }) { - const ui = resolveUiRuntime(); + const ui = resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); const storage = await loadAccounts(); if (!storage || storage.accounts.length === 0) { if (ui.v2Enabled) { @@ -4516,7 +4530,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { "Manually refresh OAuth tokens for all accounts to verify they're still valid.", args: {}, async execute() { - const ui = resolveUiRuntime(); + const ui = resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); const storage = await loadAccounts(); if (!storage || storage.accounts.length === 0) { if (ui.v2Enabled) { @@ -4604,7 +4621,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { .describe("Overwrite existing file (default: true)"), }, async execute({ path: filePath, force }) { - const ui = resolveUiRuntime(); + const ui = resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); try { await exportAccounts(filePath, force ?? true); const storage = await loadAccounts(); @@ -4653,7 +4673,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ), }, async execute({ path: filePath }) { - const ui = resolveUiRuntime(); + const ui = resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); try { const result = await importAccounts(filePath); invalidateAccountManagerCache(); From bf056b5511192715593e89acc12bc8f94fe7b945 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 20:33:34 +0800 Subject: [PATCH 146/376] refactor: route runtime live sync helper --- index.ts | 44 ++++++++++++++++++++------------------------ 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/index.ts b/index.ts index 2a70bcf4..7095188f 100644 --- a/index.ts +++ b/index.ts @@ -457,29 +457,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }, }); - const ensureLiveAccountSync = async ( - pluginConfig: ReturnType, - authFallback?: OAuthAuthDetails, - ): Promise => { - const ensured = await ensureRuntimeLiveAccountSync({ - pluginConfig, - authFallback, - getLiveAccountSync, - getStoragePath, - currentSync: liveAccountSync, - currentPath: liveAccountSyncPath, - createSync: (onChange, options) => new LiveAccountSync(onChange, options), - reloadAccountManagerFromDisk, - getLiveAccountSyncDebounceMs, - getLiveAccountSyncPollMs, - registerCleanup, - logWarn, - pluginName: PLUGIN_NAME, - }); - liveAccountSync = ensured.sync; - liveAccountSyncPath = ensured.path; - }; - const ensureRefreshGuardian = ( pluginConfig: ReturnType, ): void => { @@ -665,7 +642,26 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { resolveMutex = resolve; }); try { - await ensureLiveAccountSync(pluginConfig, auth); + { + const ensured = await ensureRuntimeLiveAccountSync({ + pluginConfig, + authFallback: auth, + getLiveAccountSync, + getStoragePath, + currentSync: liveAccountSync, + currentPath: liveAccountSyncPath, + createSync: (onChange, options) => + new LiveAccountSync(onChange, options), + reloadAccountManagerFromDisk, + getLiveAccountSyncDebounceMs, + getLiveAccountSyncPollMs, + registerCleanup, + logWarn, + pluginName: PLUGIN_NAME, + }); + liveAccountSync = ensured.sync; + liveAccountSyncPath = ensured.path; + } if (!accountManagerPromise) { await reloadAccountManagerFromDisk(auth as OAuthAuthDetails); } From e148fdb3a62bfe92596603142244174f94eaf8ab Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 20:38:01 +0800 Subject: [PATCH 147/376] refactor: route runtime toast helper directly --- index.ts | 44 +++++++++++++++++++++----------------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/index.ts b/index.ts index cd3eb9fb..d2452c0a 100644 --- a/index.ts +++ b/index.ts @@ -306,12 +306,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { MODEL_FAMILIES, }); - const showToast = async ( - message: string, - variant: "info" | "success" | "warning" | "error" = "success", - options?: { title?: string; duration?: number }, - ): Promise => showRuntimeToast(client, message, variant, options); - const hydrateEmails = async ( storage: AccountStorageV3 | null, ): Promise => { @@ -630,7 +624,11 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { await reloadAccountManagerFromDisk(); } - await showToast(`Switched to account ${index + 1}`, "info"); + await showRuntimeToast( + client, + `Switched to account ${index + 1}`, + "info", + ); } } } catch (error) { @@ -826,7 +824,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { : null; checkAndNotify(async (message, variant) => { - await showToast(message, variant); + await showRuntimeToast(client, message, variant); }).catch((err) => { logDebug( `Update check failed: ${err instanceof Error ? err.message : String(err)}`, @@ -1039,7 +1037,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const remaining = Math.max(0, endTime - Date.now()); const waitLabel = formatWaitTime(remaining); - await showToast( + await showRuntimeToast(client, `${message} (${waitLabel} remaining)`, "warning", { @@ -1206,7 +1204,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { accountManager.removeAccount(account); sessionAffinityStore?.reindexAfterRemoval(removedIndex); accountManager.saveToDiskDebounced(); - await showToast( + await showRuntimeToast(client, `Removed ${accountLabel} after ${failures} consecutive auth failures. Run 'codex login' to re-add.`, "error", { duration: toastDurationMs * 2 }, @@ -1298,7 +1296,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { account, account.index, ); - await showToast( + await showRuntimeToast(client, `Using ${accountLabel} (${account.index + 1}/${accountCount})`, "info", ); @@ -1677,7 +1675,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { fallbackReason: "unsupported-model-entitlement", }, ); - await showToast( + await showRuntimeToast(client, `Model ${previousModel} is not available for this account. Retrying with ${model}.`, "warning", { duration: toastDurationMs }, @@ -1710,7 +1708,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { fallbackReason: "unsupported-model-entitlement", }, ); - await showToast( + await showRuntimeToast(client, `Model ${blockedModel} is not available for this account. Strict policy blocked automatic fallback.`, "warning", { duration: toastDurationMs }, @@ -1805,7 +1803,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const newWorkspaceName = nextWorkspace.name ?? nextWorkspace.id; - await showToast( + await showRuntimeToast(client, `Workspace ${workspaceName} disabled. Switched to ${newWorkspaceName}.`, "warning", { duration: toastDurationMs }, @@ -1830,7 +1828,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ); accountManager.saveToDiskDebounced(); - await showToast( + await showRuntimeToast(client, `All workspaces disabled for account ${account.index + 1}. Switching to another account.`, "warning", { duration: toastDurationMs }, @@ -1869,7 +1867,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const errorType = detectErrorType(errorBody); const toastContent = getRecoveryToastContent(errorType); - await showToast( + await showRuntimeToast(client, `${toastContent.title}: ${toastContent.message}`, "warning", { duration: toastDurationMs }, @@ -1968,7 +1966,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { rateLimitToastDebounceMs, ) ) { - await showToast( + await showRuntimeToast(client, `Rate limited. Retrying in ${waitLabel} (attempt ${attempt})...`, "warning", { duration: toastDurationMs }, @@ -2011,7 +2009,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { rateLimitToastDebounceMs, ) ) { - await showToast( + await showRuntimeToast(client, `Rate limited. Switching accounts (retry in ${waitLabel}).`, "warning", { duration: toastDurationMs }, @@ -2361,7 +2359,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { logWarn( `Empty response received (attempt ${emptyResponseRetries}/${emptyResponseMaxRetries}). Retrying...`, ); - await showToast( + await showRuntimeToast(client, `Empty response. Retrying (${emptyResponseRetries}/${emptyResponseMaxRetries})...`, "warning", { duration: toastDurationMs }, @@ -3547,7 +3545,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { logError( `[${PLUGIN_NAME}] Failed to persist account: [${errorCode}] ${(err as Error)?.message ?? String(err)}`, ); - await showToast(hint, "error", { + await showRuntimeToast(client, hint, "error", { title: "Account Persistence Failed", duration: 10000, }); @@ -3631,7 +3629,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } accounts.push(resolved); - await showToast( + await showRuntimeToast(client, `Account ${accounts.length} authenticated`, "success", ); @@ -3651,7 +3649,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { logError( `[${PLUGIN_NAME}] Failed to persist account: [${errorCode}] ${(err as Error)?.message ?? String(err)}`, ); - await showToast(hint, "error", { + await showRuntimeToast(client, hint, "error", { title: "Account Persistence Failed", duration: 10000, }); @@ -3738,7 +3736,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { logError( `[${PLUGIN_NAME}] Failed to persist account: [${errorCode}] ${(err as Error)?.message ?? String(err)}`, ); - await showToast(hint, "error", { + await showRuntimeToast(client, hint, "error", { title: "Account Persistence Failed", duration: 10000, }); From 4d8367a19ff7a91af9f30487d72892100c3bf266 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 20:41:02 +0800 Subject: [PATCH 148/376] refactor: route runtime status marker helper --- index.ts | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/index.ts b/index.ts index 1ad4d50b..513fea12 100644 --- a/index.ts +++ b/index.ts @@ -407,11 +407,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return applyUiRuntimeFromConfig(loadPluginConfig()); }; - const getStatusMarker = ( - ui: UiRuntimeOptions, - status: "ok" | "warning" | "error", - ): string => getRuntimeStatusMarker(ui, status); - const invalidateAccountManagerCache = (): void => { cachedAccountManager = null; accountManagerPromise = null; @@ -3894,7 +3889,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { "", formatUiItem( ui, - `${getStatusMarker(ui, "ok")} Switched to ${label}`, + `${getRuntimeStatusMarker(ui, "ok")} Switched to ${label}`, "success", ), ].join("\n"); @@ -4309,12 +4304,12 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const refreshResult = await queuedRefresh(account.refreshToken); if (refreshResult.type === "success") { results.push( - ` ${getStatusMarker(ui, "ok")} ${label}: Healthy`, + ` ${getRuntimeStatusMarker(ui, "ok")} ${label}: Healthy`, ); healthyCount++; } else { results.push( - ` ${getStatusMarker(ui, "error")} ${label}: Token refresh failed`, + ` ${getRuntimeStatusMarker(ui, "error")} ${label}: Token refresh failed`, ); unhealthyCount++; } @@ -4322,7 +4317,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const errorMsg = error instanceof Error ? error.message : String(error); results.push( - ` ${getStatusMarker(ui, "error")} ${label}: Error - ${errorMsg.slice(0, 120)}`, + ` ${getRuntimeStatusMarker(ui, "error")} ${label}: Error - ${errorMsg.slice(0, 120)}`, ); unhealthyCount++; } @@ -4470,7 +4465,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { "", formatUiItem( ui, - `${getStatusMarker(ui, "ok")} Removed: ${label}`, + `${getRuntimeStatusMarker(ui, "ok")} Removed: ${label}`, "success", ), remaining > 0 @@ -4536,12 +4531,12 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { account.accessToken = refreshResult.access; account.expiresAt = refreshResult.expires; results.push( - ` ${getStatusMarker(ui, "ok")} ${label}: Refreshed`, + ` ${getRuntimeStatusMarker(ui, "ok")} ${label}: Refreshed`, ); refreshedCount++; } else { results.push( - ` ${getStatusMarker(ui, "error")} ${label}: Failed - ${refreshResult.message ?? refreshResult.reason}`, + ` ${getRuntimeStatusMarker(ui, "error")} ${label}: Failed - ${refreshResult.message ?? refreshResult.reason}`, ); failedCount++; } @@ -4549,7 +4544,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const errorMsg = error instanceof Error ? error.message : String(error); results.push( - ` ${getStatusMarker(ui, "error")} ${label}: Error - ${errorMsg.slice(0, 120)}`, + ` ${getRuntimeStatusMarker(ui, "error")} ${label}: Error - ${errorMsg.slice(0, 120)}`, ); failedCount++; } @@ -4600,7 +4595,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { "", formatUiItem( ui, - `${getStatusMarker(ui, "ok")} Exported ${count} account(s)`, + `${getRuntimeStatusMarker(ui, "ok")} Exported ${count} account(s)`, "success", ), formatUiKeyValue(ui, "Path", filePath, "muted"), @@ -4616,7 +4611,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { "", formatUiItem( ui, - `${getStatusMarker(ui, "error")} Export failed`, + `${getRuntimeStatusMarker(ui, "error")} Export failed`, "danger", ), formatUiKeyValue(ui, "Error", msg, "danger"), @@ -4656,7 +4651,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { "", formatUiItem( ui, - `${getStatusMarker(ui, "ok")} Import complete`, + `${getRuntimeStatusMarker(ui, "ok")} Import complete`, "success", ), formatUiKeyValue(ui, "Path", filePath, "muted"), @@ -4691,7 +4686,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { "", formatUiItem( ui, - `${getStatusMarker(ui, "error")} Import failed`, + `${getRuntimeStatusMarker(ui, "error")} Import failed`, "danger", ), formatUiKeyValue(ui, "Error", msg, "danger"), From 20cfcf93c73aa47ef3a8753fe68184b7e0b7837a Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 20:43:07 +0800 Subject: [PATCH 149/376] refactor: route runtime email hydration helper --- index.ts | 42 ++++++++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/index.ts b/index.ts index 0f481481..096f649b 100644 --- a/index.ts +++ b/index.ts @@ -345,20 +345,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { options?: { title?: string; duration?: number }, ): Promise => showRuntimeToast(client, message, variant, options); - const hydrateEmails = async ( - storage: AccountStorageV3 | null, - ): Promise => - hydrateRuntimeEmails(storage, { - queuedRefresh, - extractAccountId, - sanitizeEmail, - extractAccountEmail, - shouldUpdateAccountIdFromToken, - saveAccounts, - logWarn, - pluginName: PLUGIN_NAME, - }); - const applyUiRuntimeFromConfig = ( pluginConfig: ReturnType, ): UiRuntimeOptions => { @@ -2396,7 +2382,19 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const runAccountCheck = async ( deepProbe: boolean, ): Promise => { - const loadedStorage = await hydrateEmails(await loadAccounts()); + const loadedStorage = await hydrateRuntimeEmails( + await loadAccounts(), + { + queuedRefresh, + extractAccountId, + sanitizeEmail, + extractAccountEmail, + shouldUpdateAccountIdFromToken, + saveAccounts, + logWarn, + pluginName: PLUGIN_NAME, + }, + ); const workingStorage = loadedStorage ? { ...loadedStorage, @@ -2807,7 +2805,19 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { if (!explicitLoginMode) { while (true) { - const loadedStorage = await hydrateEmails(await loadAccounts()); + const loadedStorage = await hydrateRuntimeEmails( + await loadAccounts(), + { + queuedRefresh, + extractAccountId, + sanitizeEmail, + extractAccountEmail, + shouldUpdateAccountIdFromToken, + saveAccounts, + logWarn, + pluginName: PLUGIN_NAME, + }, + ); const workingStorage = loadedStorage ? { ...loadedStorage, From 0bf6122d783fbfa05a9fbbc6200bdd79715678d5 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 20:51:43 +0800 Subject: [PATCH 150/376] refactor: route runtime account manager cache helper --- index.ts | 83 +++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 64 insertions(+), 19 deletions(-) diff --git a/index.ts b/index.ts index 8f0abde0..625035e3 100644 --- a/index.ts +++ b/index.ts @@ -416,17 +416,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { status: "ok" | "warning" | "error", ): string => getRuntimeStatusMarker(ui, status); - const invalidateAccountManagerCache = (): void => { - invalidateRuntimeAccountManagerCache({ - setCachedAccountManager: (value) => { - cachedAccountManager = value as AccountManager | null; - }, - setAccountManagerPromise: (value) => { - accountManagerPromise = value as Promise | null; - }, - }); - }; - const reloadAccountManagerFromDisk = async ( authFallback?: OAuthAuthDetails, ): Promise => @@ -3117,7 +3106,14 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { if (storageChanged) { await saveAccounts(workingStorage); - invalidateAccountManagerCache(); + invalidateRuntimeAccountManagerCache({ + setCachedAccountManager: (value) => { + cachedAccountManager = value as AccountManager | null; + }, + setAccountManagerPromise: (value) => { + accountManagerPromise = value as Promise | null; + }, + }); } if (flaggedChanged) { await saveFlaggedAccounts(flaggedStorage); @@ -3233,7 +3229,14 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { if (restored.length > 0) { await persistAccounts(restored, false); - invalidateAccountManagerCache(); + invalidateRuntimeAccountManagerCache({ + setCachedAccountManager: (value) => { + cachedAccountManager = value as AccountManager | null; + }, + setAccountManagerPromise: (value) => { + accountManagerPromise = value as Promise | null; + }, + }); } await saveFlaggedAccounts({ @@ -3361,7 +3364,14 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { flagged.refreshToken !== target.refreshToken, ), }); - invalidateAccountManagerCache(); + invalidateRuntimeAccountManagerCache({ + setCachedAccountManager: (value) => { + cachedAccountManager = value as AccountManager | null; + }, + setAccountManagerPromise: (value) => { + accountManagerPromise = value as Promise | null; + }, + }); console.log( `\nDeleted ${target.email ?? `Account ${menuResult.deleteAccountIndex + 1}`}.\n`, ); @@ -3375,7 +3385,14 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { if (target) { target.enabled = target.enabled === false ? true : false; await saveAccounts(workingStorage); - invalidateAccountManagerCache(); + invalidateRuntimeAccountManagerCache({ + setCachedAccountManager: (value) => { + cachedAccountManager = value as AccountManager | null; + }, + setAccountManagerPromise: (value) => { + accountManagerPromise = value as Promise | null; + }, + }); console.log( `\n${target.email ?? `Account ${menuResult.toggleAccountIndex + 1}`} ${target.enabled === false ? "disabled" : "enabled"}.\n`, ); @@ -3397,7 +3414,14 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { if (menuResult.deleteAll) { await clearAccounts(); await clearFlaggedAccounts(); - invalidateAccountManagerCache(); + invalidateRuntimeAccountManagerCache({ + setCachedAccountManager: (value) => { + cachedAccountManager = value as AccountManager | null; + }, + setAccountManagerPromise: (value) => { + accountManagerPromise = value as Promise | null; + }, + }); console.log( "\nCleared saved accounts from active storage. Recovery snapshots remain available. Starting fresh.\n", ); @@ -3458,7 +3482,14 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { onSuccess: async (tokens: TokenSuccessWithAccount) => { try { await persistAccounts([tokens], startFresh); - invalidateAccountManagerCache(); + invalidateRuntimeAccountManagerCache({ + setCachedAccountManager: (value) => { + cachedAccountManager = value as AccountManager | null; + }, + setAccountManagerPromise: (value) => { + accountManagerPromise = value as Promise | null; + }, + }); } catch (err) { const storagePath = getStoragePath(); const errorCode = @@ -3562,7 +3593,14 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { try { const isFirstAccount = accounts.length === 1; await persistAccounts([resolved], isFirstAccount && startFresh); - invalidateAccountManagerCache(); + invalidateRuntimeAccountManagerCache({ + setCachedAccountManager: (value) => { + cachedAccountManager = value as AccountManager | null; + }, + setAccountManagerPromise: (value) => { + accountManagerPromise = value as Promise | null; + }, + }); } catch (err) { const storagePath = getStoragePath(); const errorCode = @@ -4650,7 +4688,14 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const ui = resolveUiRuntime(); try { const result = await importAccounts(filePath); - invalidateAccountManagerCache(); + invalidateRuntimeAccountManagerCache({ + setCachedAccountManager: (value) => { + cachedAccountManager = value as AccountManager | null; + }, + setAccountManagerPromise: (value) => { + accountManagerPromise = value as Promise | null; + }, + }); const lines = [`Import complete.`, ``]; if (result.imported > 0) { lines.push(`New accounts: ${result.imported}`); From eec7ec7718b4ab21151959198f67c42467616b7a Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 20:54:55 +0800 Subject: [PATCH 151/376] refactor: route runtime ui resolver helper --- index.ts | 57 +++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 17 deletions(-) diff --git a/index.ts b/index.ts index 0a45563f..32db5332 100644 --- a/index.ts +++ b/index.ts @@ -410,13 +410,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }); }; - const resolveUiRuntime = (): UiRuntimeOptions => { - return resolveRuntimeUiOptions({ - loadPluginConfig, - applyUiRuntimeFromConfig, - }); - }; - const getStatusMarker = ( ui: UiRuntimeOptions, status: "ok" | "warning" | "error", @@ -570,7 +563,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }; // Initialize runtime UI settings once on plugin load; auth/tools refresh this dynamically. - resolveUiRuntime(); + resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); return { event: eventHandler, @@ -3689,7 +3685,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { "List all Codex OAuth accounts and the current active index.", args: {}, async execute() { - const ui = resolveUiRuntime(); + const ui = resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); const storage = await loadAccounts(); const storePath = getStoragePath(); @@ -3825,7 +3824,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ), }, async execute({ index }) { - const ui = resolveUiRuntime(); + const ui = resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); const storage = await loadAccounts(); if (!storage || storage.accounts.length === 0) { if (ui.v2Enabled) { @@ -3921,7 +3923,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { description: "Show detailed status of Codex accounts and rate limits.", args: {}, async execute() { - const ui = resolveUiRuntime(); + const ui = resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); const storage = await loadAccounts(); if (!storage || storage.accounts.length === 0) { if (ui.v2Enabled) { @@ -4092,7 +4097,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { "Show runtime request metrics for this plugin process.", args: {}, execute() { - const ui = resolveUiRuntime(); + const ui = resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); const now = Date.now(); const uptimeMs = Math.max(0, now - runtimeMetrics.startedAt); const total = runtimeMetrics.totalRequests; @@ -4294,7 +4302,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { "Check health of all Codex accounts by validating refresh tokens.", args: {}, async execute() { - const ui = resolveUiRuntime(); + const ui = resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); const storage = await loadAccounts(); if (!storage || storage.accounts.length === 0) { if (ui.v2Enabled) { @@ -4372,7 +4383,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ), }, async execute({ index }) { - const ui = resolveUiRuntime(); + const ui = resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); const storage = await loadAccounts(); if (!storage || storage.accounts.length === 0) { if (ui.v2Enabled) { @@ -4516,7 +4530,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { "Manually refresh OAuth tokens for all accounts to verify they're still valid.", args: {}, async execute() { - const ui = resolveUiRuntime(); + const ui = resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); const storage = await loadAccounts(); if (!storage || storage.accounts.length === 0) { if (ui.v2Enabled) { @@ -4604,7 +4621,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { .describe("Overwrite existing file (default: true)"), }, async execute({ path: filePath, force }) { - const ui = resolveUiRuntime(); + const ui = resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); try { await exportAccounts(filePath, force ?? true); const storage = await loadAccounts(); @@ -4653,7 +4673,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ), }, async execute({ path: filePath }) { - const ui = resolveUiRuntime(); + const ui = resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); try { const result = await importAccounts(filePath); invalidateAccountManagerCache(); From faf62e35d5e9cd6c25125aa60e724b8f64607578 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 20:57:55 +0800 Subject: [PATCH 152/376] refactor: route runtime status marker helper --- index.ts | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/index.ts b/index.ts index 1ad4d50b..513fea12 100644 --- a/index.ts +++ b/index.ts @@ -407,11 +407,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return applyUiRuntimeFromConfig(loadPluginConfig()); }; - const getStatusMarker = ( - ui: UiRuntimeOptions, - status: "ok" | "warning" | "error", - ): string => getRuntimeStatusMarker(ui, status); - const invalidateAccountManagerCache = (): void => { cachedAccountManager = null; accountManagerPromise = null; @@ -3894,7 +3889,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { "", formatUiItem( ui, - `${getStatusMarker(ui, "ok")} Switched to ${label}`, + `${getRuntimeStatusMarker(ui, "ok")} Switched to ${label}`, "success", ), ].join("\n"); @@ -4309,12 +4304,12 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const refreshResult = await queuedRefresh(account.refreshToken); if (refreshResult.type === "success") { results.push( - ` ${getStatusMarker(ui, "ok")} ${label}: Healthy`, + ` ${getRuntimeStatusMarker(ui, "ok")} ${label}: Healthy`, ); healthyCount++; } else { results.push( - ` ${getStatusMarker(ui, "error")} ${label}: Token refresh failed`, + ` ${getRuntimeStatusMarker(ui, "error")} ${label}: Token refresh failed`, ); unhealthyCount++; } @@ -4322,7 +4317,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const errorMsg = error instanceof Error ? error.message : String(error); results.push( - ` ${getStatusMarker(ui, "error")} ${label}: Error - ${errorMsg.slice(0, 120)}`, + ` ${getRuntimeStatusMarker(ui, "error")} ${label}: Error - ${errorMsg.slice(0, 120)}`, ); unhealthyCount++; } @@ -4470,7 +4465,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { "", formatUiItem( ui, - `${getStatusMarker(ui, "ok")} Removed: ${label}`, + `${getRuntimeStatusMarker(ui, "ok")} Removed: ${label}`, "success", ), remaining > 0 @@ -4536,12 +4531,12 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { account.accessToken = refreshResult.access; account.expiresAt = refreshResult.expires; results.push( - ` ${getStatusMarker(ui, "ok")} ${label}: Refreshed`, + ` ${getRuntimeStatusMarker(ui, "ok")} ${label}: Refreshed`, ); refreshedCount++; } else { results.push( - ` ${getStatusMarker(ui, "error")} ${label}: Failed - ${refreshResult.message ?? refreshResult.reason}`, + ` ${getRuntimeStatusMarker(ui, "error")} ${label}: Failed - ${refreshResult.message ?? refreshResult.reason}`, ); failedCount++; } @@ -4549,7 +4544,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const errorMsg = error instanceof Error ? error.message : String(error); results.push( - ` ${getStatusMarker(ui, "error")} ${label}: Error - ${errorMsg.slice(0, 120)}`, + ` ${getRuntimeStatusMarker(ui, "error")} ${label}: Error - ${errorMsg.slice(0, 120)}`, ); failedCount++; } @@ -4600,7 +4595,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { "", formatUiItem( ui, - `${getStatusMarker(ui, "ok")} Exported ${count} account(s)`, + `${getRuntimeStatusMarker(ui, "ok")} Exported ${count} account(s)`, "success", ), formatUiKeyValue(ui, "Path", filePath, "muted"), @@ -4616,7 +4611,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { "", formatUiItem( ui, - `${getStatusMarker(ui, "error")} Export failed`, + `${getRuntimeStatusMarker(ui, "error")} Export failed`, "danger", ), formatUiKeyValue(ui, "Error", msg, "danger"), @@ -4656,7 +4651,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { "", formatUiItem( ui, - `${getStatusMarker(ui, "ok")} Import complete`, + `${getRuntimeStatusMarker(ui, "ok")} Import complete`, "success", ), formatUiKeyValue(ui, "Path", filePath, "muted"), @@ -4691,7 +4686,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { "", formatUiItem( ui, - `${getStatusMarker(ui, "error")} Import failed`, + `${getRuntimeStatusMarker(ui, "error")} Import failed`, "danger", ), formatUiKeyValue(ui, "Error", msg, "danger"), From 0b0b7a986d2c7ee5fe4955e6705e568499a18fcc Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 21:00:27 +0800 Subject: [PATCH 153/376] refactor: route runtime toast helper directly --- index.ts | 40 +++++++++++++++++----------------------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/index.ts b/index.ts index cd3eb9fb..447b4639 100644 --- a/index.ts +++ b/index.ts @@ -306,12 +306,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { MODEL_FAMILIES, }); - const showToast = async ( - message: string, - variant: "info" | "success" | "warning" | "error" = "success", - options?: { title?: string; duration?: number }, - ): Promise => showRuntimeToast(client, message, variant, options); - const hydrateEmails = async ( storage: AccountStorageV3 | null, ): Promise => { @@ -630,7 +624,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { await reloadAccountManagerFromDisk(); } - await showToast(`Switched to account ${index + 1}`, "info"); + await showRuntimeToast(client, `Switched to account ${index + 1}`, "info"); } } } catch (error) { @@ -826,7 +820,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { : null; checkAndNotify(async (message, variant) => { - await showToast(message, variant); + await showRuntimeToast(client, message, variant); }).catch((err) => { logDebug( `Update check failed: ${err instanceof Error ? err.message : String(err)}`, @@ -1039,7 +1033,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const remaining = Math.max(0, endTime - Date.now()); const waitLabel = formatWaitTime(remaining); - await showToast( + await showRuntimeToast(client, `${message} (${waitLabel} remaining)`, "warning", { @@ -1206,7 +1200,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { accountManager.removeAccount(account); sessionAffinityStore?.reindexAfterRemoval(removedIndex); accountManager.saveToDiskDebounced(); - await showToast( + await showRuntimeToast(client, `Removed ${accountLabel} after ${failures} consecutive auth failures. Run 'codex login' to re-add.`, "error", { duration: toastDurationMs * 2 }, @@ -1298,7 +1292,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { account, account.index, ); - await showToast( + await showRuntimeToast(client, `Using ${accountLabel} (${account.index + 1}/${accountCount})`, "info", ); @@ -1677,7 +1671,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { fallbackReason: "unsupported-model-entitlement", }, ); - await showToast( + await showRuntimeToast(client, `Model ${previousModel} is not available for this account. Retrying with ${model}.`, "warning", { duration: toastDurationMs }, @@ -1710,7 +1704,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { fallbackReason: "unsupported-model-entitlement", }, ); - await showToast( + await showRuntimeToast(client, `Model ${blockedModel} is not available for this account. Strict policy blocked automatic fallback.`, "warning", { duration: toastDurationMs }, @@ -1805,7 +1799,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const newWorkspaceName = nextWorkspace.name ?? nextWorkspace.id; - await showToast( + await showRuntimeToast(client, `Workspace ${workspaceName} disabled. Switched to ${newWorkspaceName}.`, "warning", { duration: toastDurationMs }, @@ -1830,7 +1824,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ); accountManager.saveToDiskDebounced(); - await showToast( + await showRuntimeToast(client, `All workspaces disabled for account ${account.index + 1}. Switching to another account.`, "warning", { duration: toastDurationMs }, @@ -1869,7 +1863,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const errorType = detectErrorType(errorBody); const toastContent = getRecoveryToastContent(errorType); - await showToast( + await showRuntimeToast(client, `${toastContent.title}: ${toastContent.message}`, "warning", { duration: toastDurationMs }, @@ -1968,7 +1962,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { rateLimitToastDebounceMs, ) ) { - await showToast( + await showRuntimeToast(client, `Rate limited. Retrying in ${waitLabel} (attempt ${attempt})...`, "warning", { duration: toastDurationMs }, @@ -2011,7 +2005,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { rateLimitToastDebounceMs, ) ) { - await showToast( + await showRuntimeToast(client, `Rate limited. Switching accounts (retry in ${waitLabel}).`, "warning", { duration: toastDurationMs }, @@ -2361,7 +2355,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { logWarn( `Empty response received (attempt ${emptyResponseRetries}/${emptyResponseMaxRetries}). Retrying...`, ); - await showToast( + await showRuntimeToast(client, `Empty response. Retrying (${emptyResponseRetries}/${emptyResponseMaxRetries})...`, "warning", { duration: toastDurationMs }, @@ -3547,7 +3541,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { logError( `[${PLUGIN_NAME}] Failed to persist account: [${errorCode}] ${(err as Error)?.message ?? String(err)}`, ); - await showToast(hint, "error", { + await showRuntimeToast(client, hint, "error", { title: "Account Persistence Failed", duration: 10000, }); @@ -3631,7 +3625,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } accounts.push(resolved); - await showToast( + await showRuntimeToast(client, `Account ${accounts.length} authenticated`, "success", ); @@ -3651,7 +3645,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { logError( `[${PLUGIN_NAME}] Failed to persist account: [${errorCode}] ${(err as Error)?.message ?? String(err)}`, ); - await showToast(hint, "error", { + await showRuntimeToast(client, hint, "error", { title: "Account Persistence Failed", duration: 10000, }); @@ -3738,7 +3732,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { logError( `[${PLUGIN_NAME}] Failed to persist account: [${errorCode}] ${(err as Error)?.message ?? String(err)}`, ); - await showToast(hint, "error", { + await showRuntimeToast(client, hint, "error", { title: "Account Persistence Failed", duration: 10000, }); From 5489eb6d1a8008797ed08fc284833ec3b38169de Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 21:12:45 +0800 Subject: [PATCH 154/376] refactor: route runtime account reload helper --- index.ts | 127 ++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 102 insertions(+), 25 deletions(-) diff --git a/index.ts b/index.ts index 8f0abde0..fa5a3858 100644 --- a/index.ts +++ b/index.ts @@ -427,24 +427,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }); }; - const reloadAccountManagerFromDisk = async ( - authFallback?: OAuthAuthDetails, - ): Promise => - reloadRuntimeAccountManager({ - currentReloadInFlight: accountReloadInFlight, - loadFromDisk: (fallback) => AccountManager.loadFromDisk(fallback), - setCachedAccountManager: (value) => { - cachedAccountManager = value; - }, - setAccountManagerPromise: (value) => { - accountManagerPromise = value; - }, - setReloadInFlight: (value) => { - accountReloadInFlight = value; - }, - authFallback, - }); - const applyStorageScope = ( pluginConfig: ReturnType, ): void => @@ -476,7 +458,22 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { currentSync: liveAccountSync, currentPath: liveAccountSyncPath, createSync: (onChange, options) => new LiveAccountSync(onChange, options), - reloadAccountManagerFromDisk, + reloadAccountManagerFromDisk: async (fallback) => + reloadRuntimeAccountManager({ + currentReloadInFlight: accountReloadInFlight, + loadFromDisk: (reloadFallback) => + AccountManager.loadFromDisk(reloadFallback), + setCachedAccountManager: (value) => { + cachedAccountManager = value; + }, + setAccountManagerPromise: (value) => { + accountManagerPromise = value; + }, + setReloadInFlight: (value) => { + accountReloadInFlight = value; + }, + authFallback: fallback, + }), getLiveAccountSyncDebounceMs, getLiveAccountSyncPollMs, registerCleanup, @@ -548,7 +545,20 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { modelFamilies: MODEL_FAMILIES, cachedAccountManager, reloadAccountManagerFromDisk: async () => { - await reloadAccountManagerFromDisk(); + await reloadRuntimeAccountManager({ + currentReloadInFlight: accountReloadInFlight, + loadFromDisk: (fallback) => AccountManager.loadFromDisk(fallback), + setCachedAccountManager: (value) => { + cachedAccountManager = value; + }, + setAccountManagerPromise: (value) => { + accountManagerPromise = value; + }, + setReloadInFlight: (value) => { + accountReloadInFlight = value; + }, + authFallback: undefined, + }); }, setLastCodexCliActiveSyncIndex: (index) => { lastCodexCliActiveSyncIndex = index; @@ -620,11 +630,37 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { try { await ensureLiveAccountSync(pluginConfig, auth); if (!accountManagerPromise) { - await reloadAccountManagerFromDisk(auth as OAuthAuthDetails); + await reloadRuntimeAccountManager({ + currentReloadInFlight: accountReloadInFlight, + loadFromDisk: (fallback) => AccountManager.loadFromDisk(fallback), + setCachedAccountManager: (value) => { + cachedAccountManager = value; + }, + setAccountManagerPromise: (value) => { + accountManagerPromise = value; + }, + setReloadInFlight: (value) => { + accountReloadInFlight = value; + }, + authFallback: auth as OAuthAuthDetails, + }); } const managerPromise = accountManagerPromise ?? - reloadAccountManagerFromDisk(auth as OAuthAuthDetails); + reloadRuntimeAccountManager({ + currentReloadInFlight: accountReloadInFlight, + loadFromDisk: (fallback) => AccountManager.loadFromDisk(fallback), + setCachedAccountManager: (value) => { + cachedAccountManager = value; + }, + setAccountManagerPromise: (value) => { + accountManagerPromise = value; + }, + setReloadInFlight: (value) => { + accountReloadInFlight = value; + }, + authFallback: auth as OAuthAuthDetails, + }); let accountManager = await managerPromise; cachedAccountManager = accountManager; const refreshToken = auth.type === "oauth" ? auth.refresh : ""; @@ -3893,7 +3929,20 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } if (cachedAccountManager) { - await reloadAccountManagerFromDisk(); + await reloadRuntimeAccountManager({ + currentReloadInFlight: accountReloadInFlight, + loadFromDisk: (fallback) => AccountManager.loadFromDisk(fallback), + setCachedAccountManager: (value) => { + cachedAccountManager = value; + }, + setAccountManagerPromise: (value) => { + accountManagerPromise = value; + }, + setReloadInFlight: (value) => { + accountReloadInFlight = value; + }, + authFallback: undefined, + }); } const label = formatAccountLabel(account, targetIndex); @@ -4469,7 +4518,21 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } if (cachedAccountManager) { - await reloadAccountManagerFromDisk(); + await reloadRuntimeAccountManager({ + currentReloadInFlight: accountReloadInFlight, + loadFromDisk: (fallback) => + AccountManager.loadFromDisk(fallback), + setCachedAccountManager: (value) => { + cachedAccountManager = value; + }, + setAccountManagerPromise: (value) => { + accountManagerPromise = value; + }, + setReloadInFlight: (value) => { + accountReloadInFlight = value; + }, + authFallback: undefined, + }); } const remaining = storage.accounts.length; @@ -4566,7 +4629,21 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { await saveAccounts(storage); if (cachedAccountManager) { - await reloadAccountManagerFromDisk(); + await reloadRuntimeAccountManager({ + currentReloadInFlight: accountReloadInFlight, + loadFromDisk: (fallback) => + AccountManager.loadFromDisk(fallback), + setCachedAccountManager: (value) => { + cachedAccountManager = value; + }, + setAccountManagerPromise: (value) => { + accountManagerPromise = value; + }, + setReloadInFlight: (value) => { + accountReloadInFlight = value; + }, + authFallback: undefined, + }); } results.push(""); results.push( From 9d4b502e9a863444b2e6a149d5c786924c006d9f Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 21:16:20 +0800 Subject: [PATCH 155/376] refactor: route runtime account pool helper --- index.ts | 53 ++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/index.ts b/index.ts index fee58f84..8fbe956f 100644 --- a/index.ts +++ b/index.ts @@ -292,19 +292,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { logWarn: (message) => logWarn(`[${PLUGIN_NAME}] ${message}`), }); - const persistAccounts = async ( - results: TokenSuccessWithAccount[], - replaceAll: boolean = false, - ): Promise => - persistAccountPool(results, replaceAll, { - withAccountStorageTransaction, - extractAccountId, - extractAccountEmail, - sanitizeEmail, - findMatchingAccountIndex, - MODEL_FAMILIES, - }); - const showToast = async ( message: string, variant: "info" | "success" | "warning" | "error" = "success", @@ -3321,7 +3308,14 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } if (restored.length > 0) { - await persistAccounts(restored, false); + await persistAccountPool(restored, false, { + withAccountStorageTransaction, + extractAccountId, + extractAccountEmail, + sanitizeEmail, + findMatchingAccountIndex, + MODEL_FAMILIES, + }); invalidateAccountManagerCache(); } @@ -3546,7 +3540,14 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { logInfo, onSuccess: async (tokens: TokenSuccessWithAccount) => { try { - await persistAccounts([tokens], startFresh); + await persistAccountPool([tokens], startFresh, { + withAccountStorageTransaction, + extractAccountId, + extractAccountEmail, + sanitizeEmail, + findMatchingAccountIndex, + MODEL_FAMILIES, + }); invalidateAccountManagerCache(); } catch (err) { const storagePath = getStoragePath(); @@ -3650,7 +3651,18 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { try { const isFirstAccount = accounts.length === 1; - await persistAccounts([resolved], isFirstAccount && startFresh); + await persistAccountPool( + [resolved], + isFirstAccount && startFresh, + { + withAccountStorageTransaction, + extractAccountId, + extractAccountEmail, + sanitizeEmail, + findMatchingAccountIndex, + MODEL_FAMILIES, + }, + ); invalidateAccountManagerCache(); } catch (err) { const storagePath = getStoragePath(); @@ -3738,7 +3750,14 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { logInfo, onSuccess: async (tokens: TokenSuccessWithAccount) => { try { - await persistAccounts([tokens], false); + await persistAccountPool([tokens], false, { + withAccountStorageTransaction, + extractAccountId, + extractAccountEmail, + sanitizeEmail, + findMatchingAccountIndex, + MODEL_FAMILIES, + }); } catch (err) { const storagePath = getStoragePath(); const errorCode = From 32dc3664826d87c978781f892c5e612ca81d6b52 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 21:19:28 +0800 Subject: [PATCH 156/376] refactor: route runtime ui bootstrap helper --- index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/index.ts b/index.ts index 0a45563f..dbde7d48 100644 --- a/index.ts +++ b/index.ts @@ -570,7 +570,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }; // Initialize runtime UI settings once on plugin load; auth/tools refresh this dynamically. - resolveUiRuntime(); + resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); return { event: eventHandler, From 582dbc4fa0229e4e59ef60a7a52820b343cff207 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 21:27:21 +0800 Subject: [PATCH 157/376] refactor: route runtime email hydration helper --- index.ts | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/index.ts b/index.ts index 0f481481..361e8a62 100644 --- a/index.ts +++ b/index.ts @@ -345,20 +345,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { options?: { title?: string; duration?: number }, ): Promise => showRuntimeToast(client, message, variant, options); - const hydrateEmails = async ( - storage: AccountStorageV3 | null, - ): Promise => - hydrateRuntimeEmails(storage, { - queuedRefresh, - extractAccountId, - sanitizeEmail, - extractAccountEmail, - shouldUpdateAccountIdFromToken, - saveAccounts, - logWarn, - pluginName: PLUGIN_NAME, - }); - const applyUiRuntimeFromConfig = ( pluginConfig: ReturnType, ): UiRuntimeOptions => { @@ -2396,7 +2382,16 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const runAccountCheck = async ( deepProbe: boolean, ): Promise => { - const loadedStorage = await hydrateEmails(await loadAccounts()); + const loadedStorage = await hydrateRuntimeEmails(await loadAccounts(), { + queuedRefresh, + extractAccountId, + sanitizeEmail, + extractAccountEmail, + shouldUpdateAccountIdFromToken, + saveAccounts, + logWarn, + pluginName: PLUGIN_NAME, + }); const workingStorage = loadedStorage ? { ...loadedStorage, @@ -2807,7 +2802,16 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { if (!explicitLoginMode) { while (true) { - const loadedStorage = await hydrateEmails(await loadAccounts()); + const loadedStorage = await hydrateRuntimeEmails(await loadAccounts(), { + queuedRefresh, + extractAccountId, + sanitizeEmail, + extractAccountEmail, + shouldUpdateAccountIdFromToken, + saveAccounts, + logWarn, + pluginName: PLUGIN_NAME, + }); const workingStorage = loadedStorage ? { ...loadedStorage, From af115996ed6641102f4e3d02e2c5fadaf58e7b43 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 21:29:32 +0800 Subject: [PATCH 158/376] refactor: route runtime ui resolver helper --- index.ts | 57 +++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 17 deletions(-) diff --git a/index.ts b/index.ts index 0a45563f..32db5332 100644 --- a/index.ts +++ b/index.ts @@ -410,13 +410,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }); }; - const resolveUiRuntime = (): UiRuntimeOptions => { - return resolveRuntimeUiOptions({ - loadPluginConfig, - applyUiRuntimeFromConfig, - }); - }; - const getStatusMarker = ( ui: UiRuntimeOptions, status: "ok" | "warning" | "error", @@ -570,7 +563,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }; // Initialize runtime UI settings once on plugin load; auth/tools refresh this dynamically. - resolveUiRuntime(); + resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); return { event: eventHandler, @@ -3689,7 +3685,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { "List all Codex OAuth accounts and the current active index.", args: {}, async execute() { - const ui = resolveUiRuntime(); + const ui = resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); const storage = await loadAccounts(); const storePath = getStoragePath(); @@ -3825,7 +3824,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ), }, async execute({ index }) { - const ui = resolveUiRuntime(); + const ui = resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); const storage = await loadAccounts(); if (!storage || storage.accounts.length === 0) { if (ui.v2Enabled) { @@ -3921,7 +3923,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { description: "Show detailed status of Codex accounts and rate limits.", args: {}, async execute() { - const ui = resolveUiRuntime(); + const ui = resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); const storage = await loadAccounts(); if (!storage || storage.accounts.length === 0) { if (ui.v2Enabled) { @@ -4092,7 +4097,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { "Show runtime request metrics for this plugin process.", args: {}, execute() { - const ui = resolveUiRuntime(); + const ui = resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); const now = Date.now(); const uptimeMs = Math.max(0, now - runtimeMetrics.startedAt); const total = runtimeMetrics.totalRequests; @@ -4294,7 +4302,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { "Check health of all Codex accounts by validating refresh tokens.", args: {}, async execute() { - const ui = resolveUiRuntime(); + const ui = resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); const storage = await loadAccounts(); if (!storage || storage.accounts.length === 0) { if (ui.v2Enabled) { @@ -4372,7 +4383,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ), }, async execute({ index }) { - const ui = resolveUiRuntime(); + const ui = resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); const storage = await loadAccounts(); if (!storage || storage.accounts.length === 0) { if (ui.v2Enabled) { @@ -4516,7 +4530,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { "Manually refresh OAuth tokens for all accounts to verify they're still valid.", args: {}, async execute() { - const ui = resolveUiRuntime(); + const ui = resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); const storage = await loadAccounts(); if (!storage || storage.accounts.length === 0) { if (ui.v2Enabled) { @@ -4604,7 +4621,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { .describe("Overwrite existing file (default: true)"), }, async execute({ path: filePath, force }) { - const ui = resolveUiRuntime(); + const ui = resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); try { await exportAccounts(filePath, force ?? true); const storage = await loadAccounts(); @@ -4653,7 +4673,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ), }, async execute({ path: filePath }) { - const ui = resolveUiRuntime(); + const ui = resolveRuntimeUiOptions({ + loadPluginConfig, + applyUiRuntimeFromConfig, + }); try { const result = await importAccounts(filePath); invalidateAccountManagerCache(); From 3b190479aabea5eae6bd384cf7d195a1ce44cf99 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 21:34:42 +0800 Subject: [PATCH 159/376] refactor: extract dashboard settings data helpers --- lib/codex-manager/dashboard-settings-data.ts | 128 +++++++++++++++++++ lib/codex-manager/settings-hub.ts | 107 ++-------------- 2 files changed, 140 insertions(+), 95 deletions(-) create mode 100644 lib/codex-manager/dashboard-settings-data.ts diff --git a/lib/codex-manager/dashboard-settings-data.ts b/lib/codex-manager/dashboard-settings-data.ts new file mode 100644 index 00000000..e8ec0b1f --- /dev/null +++ b/lib/codex-manager/dashboard-settings-data.ts @@ -0,0 +1,128 @@ +import { + type DashboardDisplaySettings, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS, +} from "../dashboard-settings.js"; + +export function cloneDashboardSettingsData( + settings: DashboardDisplaySettings, + deps: { + resolveMenuLayoutMode: ( + settings: DashboardDisplaySettings, + ) => "compact-details" | "expanded-rows"; + normalizeStatuslineFields: ( + fields: DashboardDisplaySettings["menuStatuslineFields"], + ) => DashboardDisplaySettings["menuStatuslineFields"]; + }, +): DashboardDisplaySettings { + const layoutMode = deps.resolveMenuLayoutMode(settings); + return { + showPerAccountRows: settings.showPerAccountRows, + showQuotaDetails: settings.showQuotaDetails, + showForecastReasons: settings.showForecastReasons, + showRecommendations: settings.showRecommendations, + showLiveProbeNotes: settings.showLiveProbeNotes, + actionAutoReturnMs: settings.actionAutoReturnMs ?? 2_000, + actionPauseOnKey: settings.actionPauseOnKey ?? true, + menuAutoFetchLimits: settings.menuAutoFetchLimits ?? true, + menuSortEnabled: + settings.menuSortEnabled ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? + true, + menuSortMode: + settings.menuSortMode ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? + "ready-first", + menuSortPinCurrent: + settings.menuSortPinCurrent ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortPinCurrent ?? + false, + menuSortQuickSwitchVisibleRow: + settings.menuSortQuickSwitchVisibleRow ?? true, + uiThemePreset: settings.uiThemePreset ?? "green", + uiAccentColor: settings.uiAccentColor ?? "green", + menuShowStatusBadge: settings.menuShowStatusBadge ?? true, + menuShowCurrentBadge: settings.menuShowCurrentBadge ?? true, + menuShowLastUsed: settings.menuShowLastUsed ?? true, + menuShowQuotaSummary: settings.menuShowQuotaSummary ?? true, + menuShowQuotaCooldown: settings.menuShowQuotaCooldown ?? true, + menuShowFetchStatus: settings.menuShowFetchStatus ?? true, + menuShowDetailsForUnselectedRows: layoutMode === "expanded-rows", + menuLayoutMode: layoutMode, + menuQuotaTtlMs: settings.menuQuotaTtlMs ?? 5 * 60_000, + menuFocusStyle: settings.menuFocusStyle ?? "row-invert", + menuHighlightCurrentRow: settings.menuHighlightCurrentRow ?? true, + menuStatuslineFields: [ + ...(deps.normalizeStatuslineFields(settings.menuStatuslineFields) ?? []), + ], + }; +} + +export function dashboardSettingsDataEqual( + left: DashboardDisplaySettings, + right: DashboardDisplaySettings, + deps: { + resolveMenuLayoutMode: ( + settings: DashboardDisplaySettings, + ) => "compact-details" | "expanded-rows"; + normalizeStatuslineFields: ( + fields: DashboardDisplaySettings["menuStatuslineFields"], + ) => DashboardDisplaySettings["menuStatuslineFields"]; + }, +): boolean { + return ( + left.showPerAccountRows === right.showPerAccountRows && + left.showQuotaDetails === right.showQuotaDetails && + left.showForecastReasons === right.showForecastReasons && + left.showRecommendations === right.showRecommendations && + left.showLiveProbeNotes === right.showLiveProbeNotes && + (left.actionAutoReturnMs ?? 2_000) === + (right.actionAutoReturnMs ?? 2_000) && + (left.actionPauseOnKey ?? true) === (right.actionPauseOnKey ?? true) && + (left.menuAutoFetchLimits ?? true) === + (right.menuAutoFetchLimits ?? true) && + (left.menuSortEnabled ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? + true) === + (right.menuSortEnabled ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? + true) && + (left.menuSortMode ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? + "ready-first") === + (right.menuSortMode ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? + "ready-first") && + (left.menuSortPinCurrent ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortPinCurrent ?? + false) === + (right.menuSortPinCurrent ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortPinCurrent ?? + false) && + (left.menuSortQuickSwitchVisibleRow ?? true) === + (right.menuSortQuickSwitchVisibleRow ?? true) && + (left.uiThemePreset ?? "green") === (right.uiThemePreset ?? "green") && + (left.uiAccentColor ?? "green") === (right.uiAccentColor ?? "green") && + (left.menuShowStatusBadge ?? true) === + (right.menuShowStatusBadge ?? true) && + (left.menuShowCurrentBadge ?? true) === + (right.menuShowCurrentBadge ?? true) && + (left.menuShowLastUsed ?? true) === (right.menuShowLastUsed ?? true) && + (left.menuShowQuotaSummary ?? true) === + (right.menuShowQuotaSummary ?? true) && + (left.menuShowQuotaCooldown ?? true) === + (right.menuShowQuotaCooldown ?? true) && + (left.menuShowFetchStatus ?? true) === + (right.menuShowFetchStatus ?? true) && + deps.resolveMenuLayoutMode(left) === deps.resolveMenuLayoutMode(right) && + (left.menuQuotaTtlMs ?? 5 * 60_000) === + (right.menuQuotaTtlMs ?? 5 * 60_000) && + (left.menuFocusStyle ?? "row-invert") === + (right.menuFocusStyle ?? "row-invert") && + (left.menuHighlightCurrentRow ?? true) === + (right.menuHighlightCurrentRow ?? true) && + JSON.stringify( + deps.normalizeStatuslineFields(left.menuStatuslineFields), + ) === + JSON.stringify(deps.normalizeStatuslineFields(right.menuStatuslineFields)) + ); +} diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 615c24d9..425406a0 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -46,6 +46,10 @@ import { } from "./backend-settings-schema.js"; import { promptBehaviorSettingsPanel } from "./behavior-settings-panel.js"; import { promptDashboardDisplayPanel } from "./dashboard-display-panel.js"; +import { + cloneDashboardSettingsData, + dashboardSettingsDataEqual, +} from "./dashboard-settings-data.js"; import { type ExperimentalSettingsAction, getExperimentalSelectOptions, @@ -587,107 +591,20 @@ function buildAccountListPreview( function cloneDashboardSettings( settings: DashboardDisplaySettings, ): DashboardDisplaySettings { - const layoutMode = resolveMenuLayoutMode(settings); - return { - showPerAccountRows: settings.showPerAccountRows, - showQuotaDetails: settings.showQuotaDetails, - showForecastReasons: settings.showForecastReasons, - showRecommendations: settings.showRecommendations, - showLiveProbeNotes: settings.showLiveProbeNotes, - actionAutoReturnMs: settings.actionAutoReturnMs ?? 2_000, - actionPauseOnKey: settings.actionPauseOnKey ?? true, - menuAutoFetchLimits: settings.menuAutoFetchLimits ?? true, - menuSortEnabled: - settings.menuSortEnabled ?? - DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? - true, - menuSortMode: - settings.menuSortMode ?? - DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? - "ready-first", - menuSortPinCurrent: - settings.menuSortPinCurrent ?? - DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortPinCurrent ?? - false, - menuSortQuickSwitchVisibleRow: - settings.menuSortQuickSwitchVisibleRow ?? true, - uiThemePreset: settings.uiThemePreset ?? "green", - uiAccentColor: settings.uiAccentColor ?? "green", - menuShowStatusBadge: settings.menuShowStatusBadge ?? true, - menuShowCurrentBadge: settings.menuShowCurrentBadge ?? true, - menuShowLastUsed: settings.menuShowLastUsed ?? true, - menuShowQuotaSummary: settings.menuShowQuotaSummary ?? true, - menuShowQuotaCooldown: settings.menuShowQuotaCooldown ?? true, - menuShowFetchStatus: settings.menuShowFetchStatus ?? true, - menuShowDetailsForUnselectedRows: layoutMode === "expanded-rows", - menuLayoutMode: layoutMode, - menuQuotaTtlMs: settings.menuQuotaTtlMs ?? 5 * 60_000, - menuFocusStyle: settings.menuFocusStyle ?? "row-invert", - menuHighlightCurrentRow: settings.menuHighlightCurrentRow ?? true, - menuStatuslineFields: [ - ...normalizeStatuslineFields(settings.menuStatuslineFields), - ], - }; + return cloneDashboardSettingsData(settings, { + resolveMenuLayoutMode, + normalizeStatuslineFields, + }); } function dashboardSettingsEqual( left: DashboardDisplaySettings, right: DashboardDisplaySettings, ): boolean { - return ( - left.showPerAccountRows === right.showPerAccountRows && - left.showQuotaDetails === right.showQuotaDetails && - left.showForecastReasons === right.showForecastReasons && - left.showRecommendations === right.showRecommendations && - left.showLiveProbeNotes === right.showLiveProbeNotes && - (left.actionAutoReturnMs ?? 2_000) === - (right.actionAutoReturnMs ?? 2_000) && - (left.actionPauseOnKey ?? true) === (right.actionPauseOnKey ?? true) && - (left.menuAutoFetchLimits ?? true) === - (right.menuAutoFetchLimits ?? true) && - (left.menuSortEnabled ?? - DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? - true) === - (right.menuSortEnabled ?? - DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? - true) && - (left.menuSortMode ?? - DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? - "ready-first") === - (right.menuSortMode ?? - DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? - "ready-first") && - (left.menuSortPinCurrent ?? - DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortPinCurrent ?? - false) === - (right.menuSortPinCurrent ?? - DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortPinCurrent ?? - false) && - (left.menuSortQuickSwitchVisibleRow ?? true) === - (right.menuSortQuickSwitchVisibleRow ?? true) && - (left.uiThemePreset ?? "green") === (right.uiThemePreset ?? "green") && - (left.uiAccentColor ?? "green") === (right.uiAccentColor ?? "green") && - (left.menuShowStatusBadge ?? true) === - (right.menuShowStatusBadge ?? true) && - (left.menuShowCurrentBadge ?? true) === - (right.menuShowCurrentBadge ?? true) && - (left.menuShowLastUsed ?? true) === (right.menuShowLastUsed ?? true) && - (left.menuShowQuotaSummary ?? true) === - (right.menuShowQuotaSummary ?? true) && - (left.menuShowQuotaCooldown ?? true) === - (right.menuShowQuotaCooldown ?? true) && - (left.menuShowFetchStatus ?? true) === - (right.menuShowFetchStatus ?? true) && - resolveMenuLayoutMode(left) === resolveMenuLayoutMode(right) && - (left.menuQuotaTtlMs ?? 5 * 60_000) === - (right.menuQuotaTtlMs ?? 5 * 60_000) && - (left.menuFocusStyle ?? "row-invert") === - (right.menuFocusStyle ?? "row-invert") && - (left.menuHighlightCurrentRow ?? true) === - (right.menuHighlightCurrentRow ?? true) && - JSON.stringify(normalizeStatuslineFields(left.menuStatuslineFields)) === - JSON.stringify(normalizeStatuslineFields(right.menuStatuslineFields)) - ); + return dashboardSettingsDataEqual(left, right, { + resolveMenuLayoutMode, + normalizeStatuslineFields, + }); } function cloneBackendPluginConfig(config: PluginConfig): PluginConfig { From ca422356dea31ed1de71eee73c0c7aeddbd80f06 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 21:42:46 +0800 Subject: [PATCH 160/376] refactor: extract backend settings helpers --- lib/codex-manager/backend-settings-helpers.ts | 162 ++++++++++++++++++ lib/codex-manager/settings-hub.ts | 151 ++-------------- 2 files changed, 176 insertions(+), 137 deletions(-) create mode 100644 lib/codex-manager/backend-settings-helpers.ts diff --git a/lib/codex-manager/backend-settings-helpers.ts b/lib/codex-manager/backend-settings-helpers.ts new file mode 100644 index 00000000..49ce5e71 --- /dev/null +++ b/lib/codex-manager/backend-settings-helpers.ts @@ -0,0 +1,162 @@ +import type { PluginConfig } from "../types.js"; +import { + BACKEND_DEFAULTS, + BACKEND_NUMBER_OPTION_BY_KEY, + BACKEND_NUMBER_OPTIONS, + BACKEND_TOGGLE_OPTIONS, + type BackendNumberSettingKey, + type BackendNumberSettingOption, + type BackendSettingFocusKey, +} from "./backend-settings-schema.js"; + +export function cloneBackendPluginConfig(config: PluginConfig): PluginConfig { + const fallbackChain = config.unsupportedCodexFallbackChain; + return { + ...BACKEND_DEFAULTS, + ...config, + unsupportedCodexFallbackChain: + fallbackChain && typeof fallbackChain === "object" + ? { ...fallbackChain } + : {}, + }; +} + +export function backendSettingsSnapshot( + config: PluginConfig, +): Record { + const snapshot: Record = {}; + for (const option of BACKEND_TOGGLE_OPTIONS) { + snapshot[option.key] = + config[option.key] ?? BACKEND_DEFAULTS[option.key] ?? false; + } + for (const option of BACKEND_NUMBER_OPTIONS) { + snapshot[option.key] = + config[option.key] ?? BACKEND_DEFAULTS[option.key] ?? option.min; + } + return snapshot; +} + +export function backendSettingsEqual( + left: PluginConfig, + right: PluginConfig, +): boolean { + return ( + JSON.stringify(backendSettingsSnapshot(left)) === + JSON.stringify(backendSettingsSnapshot(right)) + ); +} + +export function formatBackendNumberValue( + option: BackendNumberSettingOption, + value: number, +): string { + if (option.unit === "percent") return `${Math.round(value)}%`; + if (option.unit === "count") return `${Math.round(value)}`; + if (value >= 60_000 && value % 60_000 === 0) { + return `${Math.round(value / 60_000)}m`; + } + if (value >= 1_000 && value % 1_000 === 0) { + return `${Math.round(value / 1_000)}s`; + } + return `${Math.round(value)}ms`; +} + +export function clampBackendNumber( + option: BackendNumberSettingOption, + value: number, +): number { + return Math.max(option.min, Math.min(option.max, Math.round(value))); +} + +export function buildBackendSettingsPreview( + config: PluginConfig, + ui: ReturnType, + focus: BackendSettingFocusKey, + deps: { + highlightPreviewToken: ( + text: string, + ui: ReturnType, + ) => string; + }, +): { label: string; hint: string } { + const liveSync = + config.liveAccountSync ?? BACKEND_DEFAULTS.liveAccountSync ?? true; + const affinity = + config.sessionAffinity ?? BACKEND_DEFAULTS.sessionAffinity ?? true; + const preemptive = + config.preemptiveQuotaEnabled ?? + BACKEND_DEFAULTS.preemptiveQuotaEnabled ?? + true; + const threshold5h = + config.preemptiveQuotaRemainingPercent5h ?? + BACKEND_DEFAULTS.preemptiveQuotaRemainingPercent5h ?? + 5; + const threshold7d = + config.preemptiveQuotaRemainingPercent7d ?? + BACKEND_DEFAULTS.preemptiveQuotaRemainingPercent7d ?? + 5; + const fetchTimeout = + config.fetchTimeoutMs ?? BACKEND_DEFAULTS.fetchTimeoutMs ?? 60_000; + const stallTimeout = + config.streamStallTimeoutMs ?? + BACKEND_DEFAULTS.streamStallTimeoutMs ?? + 45_000; + const fetchTimeoutOption = BACKEND_NUMBER_OPTION_BY_KEY.get("fetchTimeoutMs"); + const stallTimeoutOption = BACKEND_NUMBER_OPTION_BY_KEY.get( + "streamStallTimeoutMs", + ); + + const highlightIfFocused = ( + key: BackendSettingFocusKey, + text: string, + ): string => { + if (focus !== key) return text; + return deps.highlightPreviewToken(text, ui); + }; + + const label = [ + `live sync ${highlightIfFocused("liveAccountSync", liveSync ? "on" : "off")}`, + `affinity ${highlightIfFocused("sessionAffinity", affinity ? "on" : "off")}`, + `preemptive ${highlightIfFocused("preemptiveQuotaEnabled", preemptive ? "on" : "off")}`, + ].join(" | "); + + const hint = [ + `thresholds 5h<=${highlightIfFocused("preemptiveQuotaRemainingPercent5h", `${threshold5h}%`)}`, + `7d<=${highlightIfFocused("preemptiveQuotaRemainingPercent7d", `${threshold7d}%`)}`, + `timeouts ${highlightIfFocused("fetchTimeoutMs", fetchTimeoutOption ? formatBackendNumberValue(fetchTimeoutOption, fetchTimeout) : `${fetchTimeout}ms`)}/${highlightIfFocused("streamStallTimeoutMs", stallTimeoutOption ? formatBackendNumberValue(stallTimeoutOption, stallTimeout) : `${stallTimeout}ms`)}`, + ].join(" | "); + + return { label, hint }; +} + +export function buildBackendConfigPatch( + config: PluginConfig, +): Partial { + const patch: Partial = {}; + for (const option of BACKEND_TOGGLE_OPTIONS) { + const value = config[option.key]; + if (typeof value === "boolean") { + patch[option.key] = value; + } + } + for (const option of BACKEND_NUMBER_OPTIONS) { + const value = config[option.key]; + if (typeof value === "number" && Number.isFinite(value)) { + patch[option.key] = clampBackendNumber(option, value); + } + } + return patch; +} + +export function clampBackendNumberForTests( + settingKey: string, + value: number, +): number { + const option = BACKEND_NUMBER_OPTION_BY_KEY.get( + settingKey as BackendNumberSettingKey, + ); + if (!option) { + throw new Error(`Unknown backend numeric setting key: ${settingKey}`); + } + return clampBackendNumber(option, value); +} diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 425406a0..3492cab9 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -27,13 +27,20 @@ import { getUiRuntimeOptions, setUiRuntimeOptions } from "../ui/runtime.js"; import { type MenuItem, select } from "../ui/select.js"; import { getUnifiedSettingsPath } from "../unified-settings.js"; import { sleep } from "../utils.js"; +import { + backendSettingsEqual, + buildBackendConfigPatch, + buildBackendSettingsPreview, + clampBackendNumberForTests, + cloneBackendPluginConfig, + formatBackendNumberValue, +} from "./backend-settings-helpers.js"; import { BACKEND_CATEGORY_OPTIONS, BACKEND_DEFAULTS, BACKEND_NUMBER_OPTION_BY_KEY, BACKEND_NUMBER_OPTIONS, BACKEND_TOGGLE_OPTION_BY_KEY, - BACKEND_TOGGLE_OPTIONS, type BackendCategoryConfigAction, type BackendCategoryKey, type BackendCategoryOption, @@ -607,58 +614,6 @@ function dashboardSettingsEqual( }); } -function cloneBackendPluginConfig(config: PluginConfig): PluginConfig { - const fallbackChain = config.unsupportedCodexFallbackChain; - return { - ...BACKEND_DEFAULTS, - ...config, - unsupportedCodexFallbackChain: - fallbackChain && typeof fallbackChain === "object" - ? { ...fallbackChain } - : {}, - }; -} - -function backendSettingsSnapshot( - config: PluginConfig, -): Record { - const snapshot: Record = {}; - for (const option of BACKEND_TOGGLE_OPTIONS) { - snapshot[option.key] = - config[option.key] ?? BACKEND_DEFAULTS[option.key] ?? false; - } - for (const option of BACKEND_NUMBER_OPTIONS) { - snapshot[option.key] = - config[option.key] ?? BACKEND_DEFAULTS[option.key] ?? option.min; - } - return snapshot; -} - -function backendSettingsEqual( - left: PluginConfig, - right: PluginConfig, -): boolean { - return ( - JSON.stringify(backendSettingsSnapshot(left)) === - JSON.stringify(backendSettingsSnapshot(right)) - ); -} - -function formatBackendNumberValue( - option: BackendNumberSettingOption, - value: number, -): string { - if (option.unit === "percent") return `${Math.round(value)}%`; - if (option.unit === "count") return `${Math.round(value)}`; - if (value >= 60_000 && value % 60_000 === 0) { - return `${Math.round(value / 60_000)}m`; - } - if (value >= 1_000 && value % 1_000 === 0) { - return `${Math.round(value / 1_000)}s`; - } - return `${Math.round(value)}ms`; -} - function clampBackendNumber( option: BackendNumberSettingOption, value: number, @@ -666,78 +621,6 @@ function clampBackendNumber( return Math.max(option.min, Math.min(option.max, Math.round(value))); } -function buildBackendSettingsPreview( - config: PluginConfig, - ui: ReturnType, - focus: BackendSettingFocusKey = null, -): { label: string; hint: string } { - const liveSync = - config.liveAccountSync ?? BACKEND_DEFAULTS.liveAccountSync ?? true; - const affinity = - config.sessionAffinity ?? BACKEND_DEFAULTS.sessionAffinity ?? true; - const preemptive = - config.preemptiveQuotaEnabled ?? - BACKEND_DEFAULTS.preemptiveQuotaEnabled ?? - true; - const threshold5h = - config.preemptiveQuotaRemainingPercent5h ?? - BACKEND_DEFAULTS.preemptiveQuotaRemainingPercent5h ?? - 5; - const threshold7d = - config.preemptiveQuotaRemainingPercent7d ?? - BACKEND_DEFAULTS.preemptiveQuotaRemainingPercent7d ?? - 5; - const fetchTimeout = - config.fetchTimeoutMs ?? BACKEND_DEFAULTS.fetchTimeoutMs ?? 60_000; - const stallTimeout = - config.streamStallTimeoutMs ?? - BACKEND_DEFAULTS.streamStallTimeoutMs ?? - 45_000; - const fetchTimeoutOption = BACKEND_NUMBER_OPTION_BY_KEY.get("fetchTimeoutMs"); - const stallTimeoutOption = BACKEND_NUMBER_OPTION_BY_KEY.get( - "streamStallTimeoutMs", - ); - - const highlightIfFocused = ( - key: BackendSettingFocusKey, - text: string, - ): string => { - if (focus !== key) return text; - return highlightPreviewToken(text, ui); - }; - - const label = [ - `live sync ${highlightIfFocused("liveAccountSync", liveSync ? "on" : "off")}`, - `affinity ${highlightIfFocused("sessionAffinity", affinity ? "on" : "off")}`, - `preemptive ${highlightIfFocused("preemptiveQuotaEnabled", preemptive ? "on" : "off")}`, - ].join(" | "); - - const hint = [ - `thresholds 5h<=${highlightIfFocused("preemptiveQuotaRemainingPercent5h", `${threshold5h}%`)}`, - `7d<=${highlightIfFocused("preemptiveQuotaRemainingPercent7d", `${threshold7d}%`)}`, - `timeouts ${highlightIfFocused("fetchTimeoutMs", fetchTimeoutOption ? formatBackendNumberValue(fetchTimeoutOption, fetchTimeout) : `${fetchTimeout}ms`)}/${highlightIfFocused("streamStallTimeoutMs", stallTimeoutOption ? formatBackendNumberValue(stallTimeoutOption, stallTimeout) : `${stallTimeout}ms`)}`, - ].join(" | "); - - return { label, hint }; -} - -function buildBackendConfigPatch(config: PluginConfig): Partial { - const patch: Partial = {}; - for (const option of BACKEND_TOGGLE_OPTIONS) { - const value = config[option.key]; - if (typeof value === "boolean") { - patch[option.key] = value; - } - } - for (const option of BACKEND_NUMBER_OPTIONS) { - const value = config[option.key]; - if (typeof value === "number" && Number.isFinite(value)) { - patch[option.key] = clampBackendNumber(option, value); - } - } - return patch; -} - function applyUiThemeFromDashboardSettings( settings: DashboardDisplaySettings, ): void { @@ -789,16 +672,6 @@ function formatMenuQuotaTtl(ttlMs: number): string { return `${ttlMs}ms`; } -function clampBackendNumberForTests(settingKey: string, value: number): number { - const option = BACKEND_NUMBER_OPTION_BY_KEY.get( - settingKey as BackendNumberSettingKey, - ); - if (!option) { - throw new Error(`Unknown backend numeric setting key: ${settingKey}`); - } - return clampBackendNumber(option, value); -} - async function withQueuedRetryForTests( pathKey: string, task: () => Promise, @@ -1050,7 +923,9 @@ async function promptBackendCategorySettings( .filter((option): option is BackendNumberSettingOption => !!option); while (true) { - const preview = buildBackendSettingsPreview(draft, ui, focusKey); + const preview = buildBackendSettingsPreview(draft, ui, focusKey, { + highlightPreviewToken, + }); const toggleItems: MenuItem[] = toggleOptions.map((option, index) => { const enabled = @@ -1256,7 +1131,9 @@ async function promptBackendSettings( while (true) { const previewFocus = focusByCategory[activeCategory] ?? null; - const preview = buildBackendSettingsPreview(draft, ui, previewFocus); + const preview = buildBackendSettingsPreview(draft, ui, previewFocus, { + highlightPreviewToken, + }); const categoryItems: MenuItem[] = BACKEND_CATEGORY_OPTIONS.map((category, index) => { return { From 35280baa02ed289715ec48ade4edf8cebb3fdaa2 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 21:56:46 +0800 Subject: [PATCH 161/376] refactor: extract settings write queue --- lib/codex-manager/settings-hub.ts | 146 ++++------------------ lib/codex-manager/settings-write-queue.ts | 107 ++++++++++++++++ 2 files changed, 132 insertions(+), 121 deletions(-) create mode 100644 lib/codex-manager/settings-write-queue.ts diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 3492cab9..6a4e18f1 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -63,6 +63,11 @@ import { mapExperimentalMenuHotkey, mapExperimentalStatusHotkey, } from "./experimental-settings-schema.js"; +import { + RETRYABLE_SETTINGS_WRITE_CODES, + SETTINGS_WRITE_MAX_ATTEMPTS, + withQueuedRetry, +} from "./settings-write-queue.js"; import { promptStatuslineSettingsPanel } from "./statusline-settings-panel.js"; import { promptThemeSettingsPanel } from "./theme-settings-panel.js"; @@ -196,18 +201,6 @@ type SettingsHubAction = type DashboardSettingKey = keyof DashboardDisplaySettings; -const RETRYABLE_SETTINGS_WRITE_CODES = new Set([ - "EBUSY", - "EPERM", - "EAGAIN", - "ENOTEMPTY", - "EACCES", -]); -const SETTINGS_WRITE_MAX_ATTEMPTS = 4; -const SETTINGS_WRITE_BASE_DELAY_MS = 20; -const SETTINGS_WRITE_MAX_DELAY_MS = 30_000; -const settingsWriteQueues = new Map>(); - const ACCOUNT_LIST_PANEL_KEYS = [ "menuShowStatusBadge", "menuShowCurrentBadge", @@ -239,103 +232,6 @@ const THEME_PANEL_KEYS = [ "uiAccentColor", ] as const satisfies readonly DashboardSettingKey[]; -function readErrorNumber(value: unknown): number | undefined { - if (typeof value === "number" && Number.isFinite(value)) return value; - if (typeof value === "string" && value.trim().length > 0) { - const parsed = Number.parseInt(value, 10); - if (Number.isFinite(parsed)) return parsed; - } - return undefined; -} - -function getErrorStatusCode(error: unknown): number | undefined { - if (!error || typeof error !== "object") return undefined; - const record = error as Record; - return readErrorNumber(record.status) ?? readErrorNumber(record.statusCode); -} - -function getRetryAfterMs(error: unknown): number | undefined { - if (!error || typeof error !== "object") return undefined; - const record = error as Record; - return ( - readErrorNumber(record.retryAfterMs) ?? - readErrorNumber(record.retry_after_ms) ?? - readErrorNumber(record.retryAfter) ?? - readErrorNumber(record.retry_after) - ); -} - -function isRetryableSettingsWriteError(error: unknown): boolean { - const statusCode = getErrorStatusCode(error); - if (statusCode === 429) return true; - const code = (error as NodeJS.ErrnoException | undefined)?.code; - return typeof code === "string" && RETRYABLE_SETTINGS_WRITE_CODES.has(code); -} - -function resolveRetryDelayMs(error: unknown, attempt: number): number { - const retryAfterMs = getRetryAfterMs(error); - if ( - typeof retryAfterMs === "number" && - Number.isFinite(retryAfterMs) && - retryAfterMs > 0 - ) { - return Math.max( - 10, - Math.min(SETTINGS_WRITE_MAX_DELAY_MS, Math.round(retryAfterMs)), - ); - } - return Math.min( - SETTINGS_WRITE_MAX_DELAY_MS, - SETTINGS_WRITE_BASE_DELAY_MS * 2 ** attempt, - ); -} - -async function enqueueSettingsWrite( - pathKey: string, - task: () => Promise, -): Promise { - const previous = settingsWriteQueues.get(pathKey) ?? Promise.resolve(); - const queued = previous.catch(() => {}).then(task); - const queueTail = queued.then( - () => undefined, - () => undefined, - ); - settingsWriteQueues.set(pathKey, queueTail); - try { - return await queued; - } finally { - if (settingsWriteQueues.get(pathKey) === queueTail) { - settingsWriteQueues.delete(pathKey); - } - } -} - -async function withQueuedRetry( - pathKey: string, - task: () => Promise, -): Promise { - return enqueueSettingsWrite(pathKey, async () => { - let lastError: unknown; - for (let attempt = 0; attempt < SETTINGS_WRITE_MAX_ATTEMPTS; attempt += 1) { - try { - return await task(); - } catch (error) { - lastError = error; - if ( - !isRetryableSettingsWriteError(error) || - attempt + 1 >= SETTINGS_WRITE_MAX_ATTEMPTS - ) { - throw error; - } - await sleep(resolveRetryDelayMs(error, attempt)); - } - } - throw lastError instanceof Error - ? lastError - : new Error("settings save retry exhausted"); - }); -} - function copyDashboardSettingValue( target: DashboardDisplaySettings, source: DashboardDisplaySettings, @@ -394,14 +290,18 @@ async function persistDashboardSettingsSelection( ): Promise { const fallback = cloneDashboardSettings(selected); try { - return await withQueuedRetry(getDashboardSettingsPath(), async () => { - const latest = cloneDashboardSettings( - await loadDashboardDisplaySettings(), - ); - const merged = mergeDashboardSettingsForKeys(latest, selected, keys); - await saveDashboardDisplaySettings(merged); - return merged; - }); + return await withQueuedRetry( + getDashboardSettingsPath(), + async () => { + const latest = cloneDashboardSettings( + await loadDashboardDisplaySettings(), + ); + const merged = mergeDashboardSettingsForKeys(latest, selected, keys); + await saveDashboardDisplaySettings(merged); + return merged; + }, + { sleep }, + ); } catch (error) { warnPersistFailure(scope, error); return fallback; @@ -435,9 +335,13 @@ async function persistBackendConfigSelection( ): Promise { const fallback = cloneBackendPluginConfig(selected); try { - await withQueuedRetry(resolvePluginConfigSavePathKey(), async () => { - await savePluginConfig(buildBackendConfigPatch(selected)); - }); + await withQueuedRetry( + resolvePluginConfigSavePathKey(), + async () => { + await savePluginConfig(buildBackendConfigPatch(selected)); + }, + { sleep }, + ); return fallback; } catch (error) { warnPersistFailure(scope, error); @@ -676,7 +580,7 @@ async function withQueuedRetryForTests( pathKey: string, task: () => Promise, ): Promise { - return withQueuedRetry(pathKey, task); + return withQueuedRetry(pathKey, task, { sleep }); } async function persistDashboardSettingsSelectionForTests( diff --git a/lib/codex-manager/settings-write-queue.ts b/lib/codex-manager/settings-write-queue.ts new file mode 100644 index 00000000..791dc5c2 --- /dev/null +++ b/lib/codex-manager/settings-write-queue.ts @@ -0,0 +1,107 @@ +export const SETTINGS_WRITE_MAX_ATTEMPTS = 4; +export const SETTINGS_WRITE_BASE_DELAY_MS = 50; +export const SETTINGS_WRITE_MAX_DELAY_MS = 1_000; +export const RETRYABLE_SETTINGS_WRITE_CODES = new Set([ + "EBUSY", + "EPERM", + "EAGAIN", + "ENOTEMPTY", +]); + +const settingsWriteQueues = new Map>(); + +export function readErrorNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim().length > 0) { + const parsed = Number.parseInt(value, 10); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; +} + +export function getErrorStatusCode(error: unknown): number | undefined { + if (!error || typeof error !== "object") return undefined; + const record = error as Record; + return readErrorNumber(record.status) ?? readErrorNumber(record.statusCode); +} + +export function getRetryAfterMs(error: unknown): number | undefined { + if (!error || typeof error !== "object") return undefined; + const record = error as Record; + return ( + readErrorNumber(record.retryAfterMs) ?? + readErrorNumber(record.retry_after_ms) ?? + readErrorNumber(record.retryAfter) ?? + readErrorNumber(record.retry_after) + ); +} + +export function isRetryableSettingsWriteError(error: unknown): boolean { + const statusCode = getErrorStatusCode(error); + if (statusCode === 429) return true; + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return typeof code === "string" && RETRYABLE_SETTINGS_WRITE_CODES.has(code); +} + +export function resolveRetryDelayMs(error: unknown, attempt: number): number { + const retryAfterMs = getRetryAfterMs(error); + if ( + typeof retryAfterMs === "number" && + Number.isFinite(retryAfterMs) && + retryAfterMs > 0 + ) { + return Math.max( + 10, + Math.min(SETTINGS_WRITE_MAX_DELAY_MS, Math.round(retryAfterMs)), + ); + } + return Math.min( + SETTINGS_WRITE_MAX_DELAY_MS, + SETTINGS_WRITE_BASE_DELAY_MS * 2 ** attempt, + ); +} + +export async function enqueueSettingsWrite( + pathKey: string, + task: () => Promise, +): Promise { + const previous = settingsWriteQueues.get(pathKey) ?? Promise.resolve(); + const queued = previous.catch(() => {}).then(task); + const queueTail = queued.then( + () => undefined, + () => undefined, + ); + settingsWriteQueues.set(pathKey, queueTail); + try { + return await queued; + } finally { + if (settingsWriteQueues.get(pathKey) === queueTail) { + settingsWriteQueues.delete(pathKey); + } + } +} + +export async function withQueuedRetry( + pathKey: string, + task: () => Promise, + deps: { sleep: (ms: number) => Promise }, +): Promise { + return enqueueSettingsWrite(pathKey, async () => { + let lastError: unknown; + for (let attempt = 0; attempt < SETTINGS_WRITE_MAX_ATTEMPTS; attempt += 1) { + try { + return await task(); + } catch (error) { + lastError = error; + if (!isRetryableSettingsWriteError(error)) { + throw error; + } + if (attempt >= SETTINGS_WRITE_MAX_ATTEMPTS - 1) { + break; + } + await deps.sleep(resolveRetryDelayMs(error, attempt)); + } + } + throw lastError; + }); +} From c27ac5f8fd2990e5d95a41bb4297c7ec860e6c91 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 23:49:55 +0800 Subject: [PATCH 162/376] refactor: extract settings persistence helpers --- lib/codex-manager/settings-hub.ts | 60 ++++++--------------- lib/codex-manager/settings-persist-utils.ts | 44 +++++++++++++++ 2 files changed, 60 insertions(+), 44 deletions(-) create mode 100644 lib/codex-manager/settings-persist-utils.ts diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 6a4e18f1..4473a41a 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -1,4 +1,3 @@ -import { promises as fs } from "node:fs"; import { stdin as input, stdout as output } from "node:process"; import { createInterface } from "node:readline/promises"; import { loadPluginConfig, savePluginConfig } from "../config.js"; @@ -25,7 +24,6 @@ import { ANSI } from "../ui/ansi.js"; import { UI_COPY } from "../ui/copy.js"; import { getUiRuntimeOptions, setUiRuntimeOptions } from "../ui/runtime.js"; import { type MenuItem, select } from "../ui/select.js"; -import { getUnifiedSettingsPath } from "../unified-settings.js"; import { sleep } from "../utils.js"; import { backendSettingsEqual, @@ -64,10 +62,11 @@ import { mapExperimentalStatusHotkey, } from "./experimental-settings-schema.js"; import { - RETRYABLE_SETTINGS_WRITE_CODES, - SETTINGS_WRITE_MAX_ATTEMPTS, - withQueuedRetry, -} from "./settings-write-queue.js"; + readFileWithRetry, + resolvePluginConfigSavePathKey, + warnPersistFailure, +} from "./settings-persist-utils.js"; +import { withQueuedRetry } from "./settings-write-queue.js"; import { promptStatuslineSettingsPanel } from "./statusline-settings-panel.js"; import { promptThemeSettingsPanel } from "./theme-settings-panel.js"; @@ -267,22 +266,6 @@ function mergeDashboardSettingsForKeys( return cloneDashboardSettings(next); } -function resolvePluginConfigSavePathKey(): string { - const envPath = (process.env.CODEX_MULTI_AUTH_CONFIG_PATH ?? "").trim(); - return envPath.length > 0 ? envPath : getUnifiedSettingsPath(); -} - -function formatPersistError(error: unknown): string { - if (error instanceof Error) return error.message; - return String(error); -} - -function warnPersistFailure(scope: string, error: unknown): void { - console.warn( - `Settings save failed (${scope}) after retries: ${formatPersistError(error)}`, - ); -} - async function persistDashboardSettingsSelection( selected: DashboardDisplaySettings, keys: readonly DashboardSettingKey[], @@ -308,27 +291,6 @@ async function persistDashboardSettingsSelection( } } -async function readFileWithRetry(path: string): Promise { - for (let attempt = 0; ; attempt += 1) { - try { - return await fs.readFile(path, "utf-8"); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - throw error; - } - if ( - !code || - !RETRYABLE_SETTINGS_WRITE_CODES.has(code) || - attempt >= SETTINGS_WRITE_MAX_ATTEMPTS - 1 - ) { - throw error; - } - await sleep(25 * 2 ** attempt); - } - } -} - async function persistBackendConfigSelection( selected: PluginConfig, scope: string, @@ -1177,7 +1139,17 @@ async function loadExperimentalSyncTarget(): Promise< } try { const raw = JSON.parse( - await readFileWithRetry(detection.descriptor.accountPath), + await readFileWithRetry(detection.descriptor.accountPath, { + retryableCodes: new Set([ + "EBUSY", + "EPERM", + "EAGAIN", + "ENOTEMPTY", + "EACCES", + ]), + maxAttempts: 4, + sleep, + }), ); const normalized = normalizeAccountStorage(raw); if (!normalized) { diff --git a/lib/codex-manager/settings-persist-utils.ts b/lib/codex-manager/settings-persist-utils.ts new file mode 100644 index 00000000..ebff0b21 --- /dev/null +++ b/lib/codex-manager/settings-persist-utils.ts @@ -0,0 +1,44 @@ +import { promises as fs } from "node:fs"; +import { getUnifiedSettingsPath } from "../unified-settings.js"; + +export function resolvePluginConfigSavePathKey(): string { + const envPath = (process.env.CODEX_MULTI_AUTH_CONFIG_PATH ?? "").trim(); + return envPath.length > 0 ? envPath : getUnifiedSettingsPath(); +} + +export function formatPersistError(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); +} + +export function warnPersistFailure(scope: string, error: unknown): void { + console.warn( + `Settings save failed (${scope}) after retries: ${formatPersistError(error)}`, + ); +} + +export async function readFileWithRetry( + path: string, + deps: { + retryableCodes: ReadonlySet; + maxAttempts: number; + sleep: (ms: number) => Promise; + }, +): Promise { + for (let attempt = 0; ; attempt += 1) { + try { + return await fs.readFile(path, "utf-8"); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") throw error; + if ( + !code || + !deps.retryableCodes.has(code) || + attempt >= deps.maxAttempts - 1 + ) { + throw error; + } + await deps.sleep(25 * 2 ** attempt); + } + } +} From 3946bcde8f293afc9ba879506ca012bce1ab9dbc Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 00:07:03 +0800 Subject: [PATCH 163/376] refactor: extract account snapshot helpers --- lib/storage.ts | 103 ++++++++------------------------ lib/storage/account-snapshot.ts | 82 +++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 78 deletions(-) create mode 100644 lib/storage/account-snapshot.ts diff --git a/lib/storage.ts b/lib/storage.ts index 4c812b36..bee162ce 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -11,6 +11,10 @@ import { } from "./named-backup-export.js"; import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; import { AnyAccountStorageSchema, getValidationErrors } from "./schemas.js"; +import { + describeAccountSnapshot, + statSnapshot, +} from "./storage/account-snapshot.js"; import { type BackupMetadataSection, type BackupSnapshotMetadata, @@ -115,17 +119,6 @@ type AccountStorageWithMetadata = AccountStorageV3 & { restoreReason?: RestoreReason; }; -type BackupSnapshotKind = - | "accounts-primary" - | "accounts-wal" - | "accounts-backup" - | "accounts-backup-history" - | "accounts-discovered-backup" - | "flagged-primary" - | "flagged-backup" - | "flagged-backup-history" - | "flagged-discovered-backup"; - export type BackupMetadata = { accounts: BackupMetadataSection; flaggedAccounts: BackupMetadataSection; @@ -511,70 +504,6 @@ function isCacheLikeBackupArtifactName(entryName: string): boolean { return entryName.toLowerCase().includes(".cache"); } -async function statSnapshot(path: string): Promise<{ - exists: boolean; - bytes?: number; - mtimeMs?: number; -}> { - try { - const stats = await fs.stat(path); - return { exists: true, bytes: stats.size, mtimeMs: stats.mtimeMs }; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - log.warn("Failed to stat backup candidate", { - path, - error: String(error), - }); - } - return { exists: false }; - } -} - -async function describeAccountSnapshot( - path: string, - kind: BackupSnapshotKind, - index?: number, -): Promise { - const stats = await statSnapshot(path); - if (!stats.exists) { - return { kind, path, index, exists: false, valid: false }; - } - try { - const { normalized, schemaErrors, storedVersion } = - await loadAccountsFromPath(path); - return { - kind, - path, - index, - exists: true, - valid: !!normalized, - bytes: stats.bytes, - mtimeMs: stats.mtimeMs, - version: typeof storedVersion === "number" ? storedVersion : undefined, - accountCount: normalized?.accounts.length, - schemaErrors: schemaErrors.length > 0 ? schemaErrors : undefined, - }; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - log.warn("Failed to inspect account snapshot", { - path, - error: String(error), - }); - } - return { - kind, - path, - index, - exists: true, - valid: false, - bytes: stats.bytes, - mtimeMs: stats.mtimeMs, - }; - } -} - async function loadFlaggedAccountsFromPath( path: string, ): Promise { @@ -1241,10 +1170,24 @@ export async function getBackupMetadata(): Promise { flaggedPath, getAccountsWalPath, getAccountsBackupRecoveryCandidatesWithDiscovery, - describeAccountSnapshot, + describeAccountSnapshot: (path, kind, index) => + describeAccountSnapshot(path, kind, { + index, + statSnapshot: (targetPath) => + statSnapshot(targetPath, { + stat: fs.stat, + logWarn: (message, meta) => log.warn(message, meta), + }), + loadAccountsFromPath, + logWarn: (message, meta) => log.warn(message, meta), + }), describeAccountsWalSnapshot: (path) => describeAccountsWalSnapshot(path, { - statSnapshot, + statSnapshot: (targetPath) => + statSnapshot(targetPath, { + stat: fs.stat, + logWarn: (message, meta) => log.warn(message, meta), + }), readFile: fs.readFile, isRecord, computeSha256, @@ -1253,7 +1196,11 @@ export async function getBackupMetadata(): Promise { describeFlaggedSnapshot: (path, kind, index) => describeFlaggedSnapshot(path, kind, { index, - statSnapshot, + statSnapshot: (targetPath) => + statSnapshot(targetPath, { + stat: fs.stat, + logWarn: (message, meta) => log.warn(message, meta), + }), loadFlaggedAccountsFromPath, logWarn: (message, meta) => log.warn(message, meta), }), diff --git a/lib/storage/account-snapshot.ts b/lib/storage/account-snapshot.ts new file mode 100644 index 00000000..10d4330a --- /dev/null +++ b/lib/storage/account-snapshot.ts @@ -0,0 +1,82 @@ +import type { BackupSnapshotMetadata } from "./backup-metadata.js"; + +type SnapshotStats = { + exists: boolean; + bytes?: number; + mtimeMs?: number; +}; + +export async function statSnapshot( + path: string, + deps: { + stat: typeof import("node:fs").promises.stat; + logWarn?: (message: string, meta: Record) => void; + }, +): Promise { + try { + const stats = await deps.stat(path); + return { exists: true, bytes: stats.size, mtimeMs: stats.mtimeMs }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + deps.logWarn?.("Failed to stat backup candidate", { + path, + error: String(error), + }); + } + return { exists: false }; + } +} + +export async function describeAccountSnapshot( + path: string, + kind: BackupSnapshotMetadata["kind"], + deps: { + index?: number; + statSnapshot: (path: string) => Promise; + loadAccountsFromPath: (path: string) => Promise<{ + normalized: { accounts: unknown[] } | null; + schemaErrors: string[]; + storedVersion?: unknown; + }>; + logWarn?: (message: string, meta: Record) => void; + }, +): Promise { + const stats = await deps.statSnapshot(path); + if (!stats.exists) { + return { kind, path, index: deps.index, exists: false, valid: false }; + } + try { + const { normalized, schemaErrors, storedVersion } = + await deps.loadAccountsFromPath(path); + return { + kind, + path, + index: deps.index, + exists: true, + valid: !!normalized, + bytes: stats.bytes, + mtimeMs: stats.mtimeMs, + version: typeof storedVersion === "number" ? storedVersion : undefined, + accountCount: normalized?.accounts.length, + schemaErrors: schemaErrors.length > 0 ? schemaErrors : undefined, + }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + deps.logWarn?.("Failed to inspect account snapshot", { + path, + error: String(error), + }); + } + return { + kind, + path, + index: deps.index, + exists: true, + valid: false, + bytes: stats.bytes, + mtimeMs: stats.mtimeMs, + }; + } +} From cb5f45725f742a64c48c8e3605ca92a0f7d1bea1 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 00:18:04 +0800 Subject: [PATCH 164/376] refactor: extract dashboard formatters --- lib/codex-manager/dashboard-formatters.ts | 25 ++++++++++++++++++ lib/codex-manager/settings-hub.ts | 31 +++++------------------ 2 files changed, 31 insertions(+), 25 deletions(-) create mode 100644 lib/codex-manager/dashboard-formatters.ts diff --git a/lib/codex-manager/dashboard-formatters.ts b/lib/codex-manager/dashboard-formatters.ts new file mode 100644 index 00000000..ebedf8d0 --- /dev/null +++ b/lib/codex-manager/dashboard-formatters.ts @@ -0,0 +1,25 @@ +import type { DashboardAccountSortMode } from "../dashboard-settings.js"; + +export function formatDashboardSettingState(value: boolean): string { + return value ? "[x]" : "[ ]"; +} + +export function formatMenuSortMode(mode: DashboardAccountSortMode): string { + return mode === "ready-first" ? "Ready-First" : "Manual"; +} + +export function formatMenuLayoutMode( + mode: "compact-details" | "expanded-rows", +): string { + return mode === "expanded-rows" ? "Expanded Rows" : "Compact + Details Pane"; +} + +export function formatMenuQuotaTtl(ttlMs: number): string { + if (ttlMs >= 60_000 && ttlMs % 60_000 === 0) { + return `${Math.round(ttlMs / 60_000)}m`; + } + if (ttlMs >= 1_000 && ttlMs % 1_000 === 0) { + return `${Math.round(ttlMs / 1_000)}s`; + } + return `${ttlMs}ms`; +} diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 4473a41a..f0e9b108 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -3,7 +3,6 @@ import { createInterface } from "node:readline/promises"; import { loadPluginConfig, savePluginConfig } from "../config.js"; import { type DashboardAccentColor, - type DashboardAccountSortMode, type DashboardDisplaySettings, type DashboardStatuslineField, type DashboardThemePreset, @@ -51,6 +50,12 @@ import { } from "./backend-settings-schema.js"; import { promptBehaviorSettingsPanel } from "./behavior-settings-panel.js"; import { promptDashboardDisplayPanel } from "./dashboard-display-panel.js"; +import { + formatDashboardSettingState, + formatMenuLayoutMode, + formatMenuQuotaTtl, + formatMenuSortMode, +} from "./dashboard-formatters.js"; import { cloneDashboardSettingsData, dashboardSettingsDataEqual, @@ -500,14 +505,6 @@ function applyUiThemeFromDashboardSettings( }); } -function formatDashboardSettingState(value: boolean): string { - return value ? "[x]" : "[ ]"; -} - -function formatMenuSortMode(mode: DashboardAccountSortMode): string { - return mode === "ready-first" ? "Ready-First" : "Manual"; -} - function resolveMenuLayoutMode( settings: DashboardDisplaySettings, ): "compact-details" | "expanded-rows" { @@ -522,22 +519,6 @@ function resolveMenuLayoutMode( : "compact-details"; } -function formatMenuLayoutMode( - mode: "compact-details" | "expanded-rows", -): string { - return mode === "expanded-rows" ? "Expanded Rows" : "Compact + Details Pane"; -} - -function formatMenuQuotaTtl(ttlMs: number): string { - if (ttlMs >= 60_000 && ttlMs % 60_000 === 0) { - return `${Math.round(ttlMs / 60_000)}m`; - } - if (ttlMs >= 1_000 && ttlMs % 1_000 === 0) { - return `${Math.round(ttlMs / 1_000)}s`; - } - return `${ttlMs}ms`; -} - async function withQueuedRetryForTests( pathKey: string, task: () => Promise, From 34bfd85eb06ab508f9f2debf21d8cb1bf4199a6a Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 00:24:32 +0800 Subject: [PATCH 165/376] refactor: extract flagged storage file loader --- lib/storage.ts | 8 +++++--- lib/storage/flagged-storage-file.ts | 13 +++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 lib/storage/flagged-storage-file.ts diff --git a/lib/storage.ts b/lib/storage.ts index bee162ce..e5614742 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -21,6 +21,7 @@ import { buildMetadataSection, } from "./storage/backup-metadata.js"; import { formatStorageErrorHint } from "./storage/error-hints.js"; +import { loadFlaggedAccountsFromFile } from "./storage/flagged-storage-file.js"; import { collectNamedBackups, type NamedBackupSummary, @@ -507,9 +508,10 @@ function isCacheLikeBackupArtifactName(entryName: string): boolean { async function loadFlaggedAccountsFromPath( path: string, ): Promise { - const content = await fs.readFile(path, "utf-8"); - const data = JSON.parse(content) as unknown; - return normalizeFlaggedStorage(data); + return loadFlaggedAccountsFromFile(path, { + readFile: fs.readFile, + normalizeFlaggedStorage, + }); } type AccountsJournalEntry = { diff --git a/lib/storage/flagged-storage-file.ts b/lib/storage/flagged-storage-file.ts new file mode 100644 index 00000000..bf573a1d --- /dev/null +++ b/lib/storage/flagged-storage-file.ts @@ -0,0 +1,13 @@ +import type { FlaggedAccountStorageV1 } from "../storage.js"; + +export async function loadFlaggedAccountsFromFile( + path: string, + deps: { + readFile: typeof import("node:fs").promises.readFile; + normalizeFlaggedStorage: (data: unknown) => FlaggedAccountStorageV1; + }, +): Promise { + const content = await deps.readFile(path, "utf-8"); + const data = JSON.parse(content) as unknown; + return deps.normalizeFlaggedStorage(data); +} From 1df5893768892b78c9ab2b36a1fd1a7bd27226a6 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 00:32:53 +0800 Subject: [PATCH 166/376] refactor: extract storage cache artifact helper --- lib/storage.ts | 5 +---- lib/storage/cache-artifacts.ts | 3 +++ 2 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 lib/storage/cache-artifacts.ts diff --git a/lib/storage.ts b/lib/storage.ts index e5614742..fe815ff1 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -20,6 +20,7 @@ import { type BackupSnapshotMetadata, buildMetadataSection, } from "./storage/backup-metadata.js"; +import { isCacheLikeBackupArtifactName } from "./storage/cache-artifacts.js"; import { formatStorageErrorHint } from "./storage/error-hints.js"; import { loadFlaggedAccountsFromFile } from "./storage/flagged-storage-file.js"; import { @@ -501,10 +502,6 @@ function withRestoreMetadata( }; } -function isCacheLikeBackupArtifactName(entryName: string): boolean { - return entryName.toLowerCase().includes(".cache"); -} - async function loadFlaggedAccountsFromPath( path: string, ): Promise { diff --git a/lib/storage/cache-artifacts.ts b/lib/storage/cache-artifacts.ts new file mode 100644 index 00000000..4b192049 --- /dev/null +++ b/lib/storage/cache-artifacts.ts @@ -0,0 +1,3 @@ +export function isCacheLikeBackupArtifactName(entryName: string): boolean { + return entryName.toLowerCase().includes(".cache"); +} From 1fe2c2df7cb4c929cf564871b4dd9a89aaeb7424 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 00:36:55 +0800 Subject: [PATCH 167/376] refactor: extract storage hash helper --- lib/storage.ts | 6 +----- lib/storage/hash.ts | 5 +++++ 2 files changed, 6 insertions(+), 5 deletions(-) create mode 100644 lib/storage/hash.ts diff --git a/lib/storage.ts b/lib/storage.ts index fe815ff1..2ecb6dbe 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1,5 +1,4 @@ import { AsyncLocalStorage } from "node:async_hooks"; -import { createHash } from "node:crypto"; import { existsSync, promises as fs } from "node:fs"; import { basename, dirname, join } from "node:path"; import { ACCOUNT_LIMITS } from "./constants.js"; @@ -23,6 +22,7 @@ import { import { isCacheLikeBackupArtifactName } from "./storage/cache-artifacts.js"; import { formatStorageErrorHint } from "./storage/error-hints.js"; import { loadFlaggedAccountsFromFile } from "./storage/flagged-storage-file.js"; +import { computeSha256 } from "./storage/hash.js"; import { collectNamedBackups, type NamedBackupSummary, @@ -472,10 +472,6 @@ async function cleanupStaleRotatingBackupArtifacts( } } -function computeSha256(value: string): string { - return createHash("sha256").update(value).digest("hex"); -} - function createEmptyStorageWithMetadata( restoreEligible: boolean, restoreReason: RestoreReason, diff --git a/lib/storage/hash.ts b/lib/storage/hash.ts new file mode 100644 index 00000000..e2285c95 --- /dev/null +++ b/lib/storage/hash.ts @@ -0,0 +1,5 @@ +import { createHash } from "node:crypto"; + +export function computeSha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} From d1e5cb5d8f6686f76c1e57436477343910d25680 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 00:38:08 +0800 Subject: [PATCH 168/376] fix: address runtime account check bugs --- lib/runtime/account-check.ts | 2 +- scripts/check-pack-budget.mjs | 2 +- scripts/verify-vendor-provenance.mjs | 2 +- test/runtime-account-check.test.ts | 66 ++++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 test/runtime-account-check.test.ts diff --git a/lib/runtime/account-check.ts b/lib/runtime/account-check.ts index 69238200..2b2b2276 100644 --- a/lib/runtime/account-check.ts +++ b/lib/runtime/account-check.ts @@ -198,7 +198,7 @@ export async function runRuntimeAccountCheck( ); const flaggedRecord: FlaggedAccountMetadataV1 = { ...account, - flaggedAt: deps.now?.() ?? Date.now(), + flaggedAt: nowMs, flaggedReason: "token-invalid", lastError: message, }; diff --git a/scripts/check-pack-budget.mjs b/scripts/check-pack-budget.mjs index 3659724e..0ea0148a 100644 --- a/scripts/check-pack-budget.mjs +++ b/scripts/check-pack-budget.mjs @@ -20,7 +20,7 @@ const FORBIDDEN_PREFIXES = [ ".github/", "test/", "src/", - "tmp", + "tmp/", ".tmp/", ".codex/", ]; diff --git a/scripts/verify-vendor-provenance.mjs b/scripts/verify-vendor-provenance.mjs index ccf075c7..46cfbc73 100644 --- a/scripts/verify-vendor-provenance.mjs +++ b/scripts/verify-vendor-provenance.mjs @@ -24,7 +24,7 @@ for (const component of manifest.components) { if (!file?.path || !file?.sha256) { throw new Error(`Invalid file provenance entry in ${component.name}`); } - const content = await readFile(file.path); + const content = await readFile(new URL(`../${file.path}`, import.meta.url)); const actual = createHash("sha256").update(content).digest("hex"); if (actual !== file.sha256) { throw new Error( diff --git a/test/runtime-account-check.test.ts b/test/runtime-account-check.test.ts new file mode 100644 index 00000000..f7e63139 --- /dev/null +++ b/test/runtime-account-check.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest"; +import { runRuntimeAccountCheck } from "../lib/runtime/account-check.js"; + +describe("runRuntimeAccountCheck", () => { + it("reuses the current time when flagging an invalid refresh token", async () => { + const saveFlaggedAccounts = vi.fn(async () => {}); + const now = vi.fn(() => 1000 + now.mock.calls.length - 1); + + await runRuntimeAccountCheck(true, { + hydrateEmails: async (storage) => storage, + loadAccounts: async () => ({ + version: 3, + accounts: [ + { + email: "one@example.com", + refreshToken: "refresh-1", + accessToken: undefined, + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }), + createEmptyStorage: () => ({ version: 3, accounts: [], activeIndex: 0, activeIndexByFamily: {} }), + loadFlaggedAccounts: async () => ({ version: 1, accounts: [] }), + createAccountCheckWorkingState: (flaggedStorage) => ({ + flaggedStorage, + removeFromActive: new Set(), + storageChanged: false, + flaggedChanged: false, + ok: 0, + errors: 0, + disabled: 0, + }), + lookupCodexCliTokensByEmail: async () => null, + extractAccountId: () => undefined, + shouldUpdateAccountIdFromToken: () => false, + sanitizeEmail: (email) => email, + extractAccountEmail: () => undefined, + queuedRefresh: async () => ({ + type: "failed", + reason: "invalid_grant", + message: "refresh failed", + }), + isRuntimeFlaggableFailure: () => true, + fetchCodexQuotaSnapshot: async () => { + throw new Error("should not probe quota in deep mode"); + }, + resolveRequestAccountId: () => undefined, + formatCodexQuotaLine: () => "quota", + clampRuntimeActiveIndices: vi.fn(), + MODEL_FAMILIES: ["codex"], + saveAccounts: vi.fn(async () => {}), + invalidateAccountManagerCache: vi.fn(), + saveFlaggedAccounts, + now, + showLine: vi.fn(), + }); + + const flaggedStorage = saveFlaggedAccounts.mock.calls[0]?.[0]; + expect(flaggedStorage.accounts).toHaveLength(1); + expect(flaggedStorage.accounts[0]?.flaggedAt).toBe(1000); + expect(now).toHaveBeenCalledTimes(1); + }); +}); From 57575ab374485294653bccce5b0e5febd90b3a2f Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 00:38:08 +0800 Subject: [PATCH 169/376] fix: narrow tmp pack budget prefix --- scripts/check-pack-budget.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check-pack-budget.mjs b/scripts/check-pack-budget.mjs index 3659724e..0ea0148a 100644 --- a/scripts/check-pack-budget.mjs +++ b/scripts/check-pack-budget.mjs @@ -20,7 +20,7 @@ const FORBIDDEN_PREFIXES = [ ".github/", "test/", "src/", - "tmp", + "tmp/", ".tmp/", ".codex/", ]; From f1530725ff0bcb49e51667d71a992fe13faeabf1 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 00:38:08 +0800 Subject: [PATCH 170/376] fix: resolve vendor provenance paths from script --- scripts/verify-vendor-provenance.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/verify-vendor-provenance.mjs b/scripts/verify-vendor-provenance.mjs index ccf075c7..46cfbc73 100644 --- a/scripts/verify-vendor-provenance.mjs +++ b/scripts/verify-vendor-provenance.mjs @@ -24,7 +24,7 @@ for (const component of manifest.components) { if (!file?.path || !file?.sha256) { throw new Error(`Invalid file provenance entry in ${component.name}`); } - const content = await readFile(file.path); + const content = await readFile(new URL(`../${file.path}`, import.meta.url)); const actual = createHash("sha256").update(content).digest("hex"); if (actual !== file.sha256) { throw new Error( From 2412b22f6f5fef1a2ad807eab9d17502751b1a7c Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 00:38:08 +0800 Subject: [PATCH 171/376] fix: add missing storage ENOENT hint --- lib/storage/error-hints.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/storage/error-hints.ts b/lib/storage/error-hints.ts index e5c79532..04a63bc9 100644 --- a/lib/storage/error-hints.ts +++ b/lib/storage/error-hints.ts @@ -17,6 +17,8 @@ export function formatStorageErrorHint(error: unknown, path: string): string { : `Permission denied writing to ${path}. Check folder permissions. Try: chmod 755 ~/.codex`; case "EBUSY": return `File is locked at ${path}. The file may be open in another program. Close any editors or processes accessing it.`; + case "ENOENT": + return `Path does not exist: ${path}. Create the parent folder and try again.`; case "ENOSPC": return `Disk is full. Free up space and try again. Path: ${path}`; default: From e94216a7fdb3ae977acd732c08ff793c8b642ff1 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 00:38:08 +0800 Subject: [PATCH 172/376] fix: guard account-select events without properties --- lib/runtime/account-select-event.ts | 2 +- test/runtime-account-select-event.test.ts | 31 +++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 test/runtime-account-select-event.test.ts diff --git a/lib/runtime/account-select-event.ts b/lib/runtime/account-select-event.ts index be80062c..1fae958b 100644 --- a/lib/runtime/account-select-event.ts +++ b/lib/runtime/account-select-event.ts @@ -25,7 +25,7 @@ export async function handleAccountSelectEvent(input: { return false; } - const props = event.properties as { + const props = (event.properties ?? {}) as { index?: number; accountIndex?: number; provider?: string; diff --git a/test/runtime-account-select-event.test.ts b/test/runtime-account-select-event.test.ts new file mode 100644 index 00000000..c5bb7851 --- /dev/null +++ b/test/runtime-account-select-event.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from "vitest"; +import { handleAccountSelectEvent } from "../lib/runtime/account-select-event.js"; + +describe("handleAccountSelectEvent", () => { + it("ignores account-select events without properties", async () => { + const loadAccounts = vi.fn(); + const saveAccounts = vi.fn(); + const cachedAccountManager = { + syncCodexCliActiveSelectionForIndex: vi.fn(), + }; + const showToast = vi.fn(async () => {}); + + const handled = await handleAccountSelectEvent({ + event: { type: "account.select" }, + providerId: "openai", + loadAccounts, + saveAccounts, + modelFamilies: ["codex"], + cachedAccountManager, + reloadAccountManagerFromDisk: vi.fn(async () => null), + setLastCodexCliActiveSyncIndex: vi.fn(), + showToast, + }); + + expect(handled).toBe(true); + expect(loadAccounts).not.toHaveBeenCalled(); + expect(saveAccounts).not.toHaveBeenCalled(); + expect(cachedAccountManager.syncCodexCliActiveSelectionForIndex).not.toHaveBeenCalled(); + expect(showToast).not.toHaveBeenCalled(); + }); +}); From 5a9105843aa759556db64a967a7f33b85d3d1829 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 00:43:12 +0800 Subject: [PATCH 173/376] refactor: extract restore metadata helpers --- lib/storage.ts | 43 ++++++--------------------------- lib/storage/restore-metadata.ts | 34 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 35 deletions(-) create mode 100644 lib/storage/restore-metadata.ts diff --git a/lib/storage.ts b/lib/storage.ts index 2ecb6dbe..f9db948d 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -31,6 +31,10 @@ import { buildRestoreAssessment, collectBackupMetadata, } from "./storage/restore-assessment.js"; +import { + createEmptyStorageWithRestoreMetadata, + withRestoreMetadata, +} from "./storage/restore-metadata.js"; import { describeAccountsWalSnapshot, describeFlaggedSnapshot, @@ -116,11 +120,6 @@ export interface FlaggedAccountStorageV1 { type RestoreReason = "empty-storage" | "intentional-reset" | "missing-storage"; -type AccountStorageWithMetadata = AccountStorageV3 & { - restoreEligible?: boolean; - restoreReason?: RestoreReason; -}; - export type BackupMetadata = { accounts: BackupMetadataSection; flaggedAccounts: BackupMetadataSection; @@ -472,32 +471,6 @@ async function cleanupStaleRotatingBackupArtifacts( } } -function createEmptyStorageWithMetadata( - restoreEligible: boolean, - restoreReason: RestoreReason, -): AccountStorageWithMetadata { - return { - version: 3, - accounts: [], - activeIndex: 0, - activeIndexByFamily: {}, - restoreEligible, - restoreReason, - }; -} - -function withRestoreMetadata( - storage: AccountStorageV3, - restoreEligible: boolean, - restoreReason: RestoreReason, -): AccountStorageWithMetadata { - return { - ...storage, - restoreEligible, - restoreReason, - }; -} - async function loadFlaggedAccountsFromPath( path: string, ): Promise { @@ -1313,7 +1286,7 @@ async function loadAccountsInternal( } if (existsSync(resetMarkerPath)) { - return createEmptyStorageWithMetadata(false, "intentional-reset"); + return createEmptyStorageWithRestoreMetadata(false, "intentional-reset"); } if (normalized && normalized.accounts.length === 0) { @@ -1370,7 +1343,7 @@ async function loadAccountsInternal( } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (existsSync(resetMarkerPath)) { - return createEmptyStorageWithMetadata(false, "intentional-reset"); + return createEmptyStorageWithRestoreMetadata(false, "intentional-reset"); } if (code === "ENOENT" && migratedLegacyStorage) { return migratedLegacyStorage; @@ -1391,7 +1364,7 @@ async function loadAccountsInternal( return recoveredFromWal; } if (existsSync(resetMarkerPath)) { - return createEmptyStorageWithMetadata(false, "intentional-reset"); + return createEmptyStorageWithRestoreMetadata(false, "intentional-reset"); } if (storageBackupEnabled) { @@ -1439,7 +1412,7 @@ async function loadAccountsInternal( log.error("Failed to load account storage", { error: String(error) }); } if (code === "ENOENT") { - return createEmptyStorageWithMetadata(true, "missing-storage"); + return createEmptyStorageWithRestoreMetadata(true, "missing-storage"); } return null; } diff --git a/lib/storage/restore-metadata.ts b/lib/storage/restore-metadata.ts new file mode 100644 index 00000000..28354529 --- /dev/null +++ b/lib/storage/restore-metadata.ts @@ -0,0 +1,34 @@ +import type { AccountStorageV3 } from "../storage.js"; + +type RestoreReason = "empty-storage" | "intentional-reset" | "missing-storage"; + +type AccountStorageWithMetadata = AccountStorageV3 & { + restoreEligible?: boolean; + restoreReason?: RestoreReason; +}; + +export function createEmptyStorageWithRestoreMetadata( + restoreEligible: boolean, + restoreReason: RestoreReason, +): AccountStorageWithMetadata { + return { + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + restoreEligible, + restoreReason, + }; +} + +export function withRestoreMetadata( + storage: AccountStorageV3, + restoreEligible: boolean, + restoreReason: RestoreReason, +): AccountStorageWithMetadata { + return { + ...storage, + restoreEligible, + restoreReason, + }; +} From cc40cc3ae3af6d7fe4b6712d36a19d66f81f4376 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 00:49:18 +0800 Subject: [PATCH 174/376] refactor: extract account match utils --- lib/storage.ts | 58 +++++------------------------- lib/storage/account-match-utils.ts | 55 ++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 49 deletions(-) create mode 100644 lib/storage/account-match-utils.ts diff --git a/lib/storage.ts b/lib/storage.ts index f9db948d..f2e7238f 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -47,6 +47,11 @@ export { } from "./storage/identity.js"; export type { NamedBackupSummary } from "./storage/named-backups.js"; +import { + collectDistinctIdentityValues, + findNewestMatchingIndex, + selectNewestAccount, +} from "./storage/account-match-utils.js"; import { getFlaggedAccountsPath as buildFlaggedAccountsPath, getLegacyFlaggedAccountsPath as buildLegacyFlaggedAccountsPath, @@ -729,59 +734,10 @@ async function loadNormalizedStorageFromPath( }); } -function selectNewestAccount( - current: T | undefined, - candidate: T, -): T { - if (!current) return candidate; - const currentLastUsed = current.lastUsed || 0; - const candidateLastUsed = candidate.lastUsed || 0; - if (candidateLastUsed > currentLastUsed) return candidate; - if (candidateLastUsed < currentLastUsed) return current; - const currentAddedAt = current.addedAt || 0; - const candidateAddedAt = candidate.addedAt || 0; - return candidateAddedAt >= currentAddedAt ? candidate : current; -} - -function collectDistinctIdentityValues( - values: Array, -): Set { - const distinct = new Set(); - for (const value of values) { - if (value) distinct.add(value); - } - return distinct; -} - type AccountMatchOptions = { allowUniqueAccountIdFallbackWithoutEmail?: boolean; }; -function findNewestMatchingIndex( - accounts: readonly T[], - predicate: (ref: AccountIdentityRef) => boolean, -): number | undefined { - let matchIndex: number | undefined; - let match: T | undefined; - for (let i = 0; i < accounts.length; i += 1) { - const account = accounts[i]; - if (!account) continue; - const ref = toAccountIdentityRef(account); - if (!predicate(ref)) continue; - if (matchIndex === undefined) { - matchIndex = i; - match = account; - continue; - } - const newest = selectNewestAccount(match, account); - if (newest === account) { - matchIndex = i; - match = account; - } - } - return matchIndex; -} - function findCompositeAccountMatchIndex( accounts: readonly T[], candidateRef: AccountIdentityRef, @@ -789,9 +745,11 @@ function findCompositeAccountMatchIndex( if (!candidateRef.accountId || !candidateRef.emailKey) return undefined; return findNewestMatchingIndex( accounts, + (account) => toAccountIdentityRef(account), (ref) => ref.accountId === candidateRef.accountId && ref.emailKey === candidateRef.emailKey, + selectNewestAccount, ); } @@ -819,7 +777,9 @@ function findSafeEmailMatchIndex( return findNewestMatchingIndex( accounts, + (account) => toAccountIdentityRef(account), (ref) => ref.emailKey === candidateRef.emailKey, + selectNewestAccount, ); } diff --git a/lib/storage/account-match-utils.ts b/lib/storage/account-match-utils.ts new file mode 100644 index 00000000..713944eb --- /dev/null +++ b/lib/storage/account-match-utils.ts @@ -0,0 +1,55 @@ +type AccountLike = { + addedAt?: number; + lastUsed?: number; +}; + +export function selectNewestAccount( + current: T | undefined, + candidate: T, +): T { + if (!current) return candidate; + const currentLastUsed = current.lastUsed || 0; + const candidateLastUsed = candidate.lastUsed || 0; + if (candidateLastUsed > currentLastUsed) return candidate; + if (candidateLastUsed < currentLastUsed) return current; + const currentAddedAt = current.addedAt || 0; + const candidateAddedAt = candidate.addedAt || 0; + return candidateAddedAt >= currentAddedAt ? candidate : current; +} + +export function collectDistinctIdentityValues( + values: Array, +): Set { + const distinct = new Set(); + for (const value of values) { + if (value) distinct.add(value); + } + return distinct; +} + +export function findNewestMatchingIndex( + accounts: readonly T[], + toRef: (account: T) => TRef, + predicate: (ref: TRef) => boolean, + selectNewest: (current: T | undefined, candidate: T) => T, +): number | undefined { + let matchIndex: number | undefined; + let match: T | undefined; + for (let i = 0; i < accounts.length; i += 1) { + const account = accounts[i]; + if (!account) continue; + const ref = toRef(account); + if (!predicate(ref)) continue; + if (matchIndex === undefined) { + matchIndex = i; + match = account; + continue; + } + const newest = selectNewest(match, account); + if (newest === account) { + matchIndex = i; + match = account; + } + } + return matchIndex; +} From 757d65c12c1cbf574309a8c63e03fe3b8d1a101a Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 00:49:51 +0800 Subject: [PATCH 175/376] fix: advertise report explain mode --- lib/codex-manager/commands/report.ts | 3 ++- test/codex-manager-cli.test.ts | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/codex-manager/commands/report.ts b/lib/codex-manager/commands/report.ts index 56d68108..09cfe4b3 100644 --- a/lib/codex-manager/commands/report.ts +++ b/lib/codex-manager/commands/report.ts @@ -56,11 +56,12 @@ export interface ReportCommandDeps { function printReportUsage(logInfo: (message: string) => void): void { logInfo( [ - "Usage: codex auth report [--live] [--json] [--model MODEL] [--out PATH]", + "Usage: codex auth report [--live] [--json] [--explain] [--model MODEL] [--out PATH]", "", "Options:", " --live, -l Probe live quota headers via Codex backend", " --json, -j Print machine-readable JSON output", + " --explain Print per-account reasoning in text mode", " --model, -m Probe model for live mode (default: gpt-5-codex)", " --out Write JSON report to a file path", ].join("\n"), diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index dd621cf5..c3faca21 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -778,7 +778,9 @@ describe("codex manager cli commands", () => { ]); expect(exitCode).toBe(0); - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Account 1:")); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("Account 1: ready, low risk"), + ); }); it("prints populated account status for auth status", async () => { From 145b53c66de4ae34fa68baa9886e1303e7b4d590 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 00:49:51 +0800 Subject: [PATCH 176/376] fix: cover config explain command paths --- lib/config.ts | 4 +--- test/codex-manager-cli.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/lib/config.ts b/lib/config.ts index f806ec22..300aa1bb 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -1441,9 +1441,7 @@ export function getPluginConfigExplainReport(): ConfigExplainReport { ) ? "env" : storedRecord && Object.hasOwn(storedRecord, entry.key) - ? stored.storageKind === "none" - ? "default" - : stored.storageKind + ? stored.storageKind : "default"; return { diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index bc41d910..0d361016 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -676,6 +676,29 @@ describe("codex manager cli commands", () => { expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"entries"')); }); + it("prints config explain output in text mode", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "config", "explain"]); + + expect(exitCode).toBe(0); + expect(logSpy).toHaveBeenCalledWith("Config storage: unified"); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("codexMode = "), + ); + }); + + it("errors for unknown config subcommands", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "config", "unknown"]); + + expect(exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith("Unknown config command: unknown"); + }); + it("prints populated account status for auth status", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValueOnce({ From d0d1d1aba17c7ce7a00b1df6afe0c0ccaaf41bac Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 00:49:51 +0800 Subject: [PATCH 177/376] fix: preserve zero-duration runtime toasts --- lib/runtime/toast.ts | 2 +- test/runtime-toast.test.ts | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 test/runtime-toast.test.ts diff --git a/lib/runtime/toast.ts b/lib/runtime/toast.ts index d4c19e18..c3921f8a 100644 --- a/lib/runtime/toast.ts +++ b/lib/runtime/toast.ts @@ -21,7 +21,7 @@ export async function showRuntimeToast( message, variant, ...(options?.title && { title: options.title }), - ...(options?.duration && { duration: options.duration }), + ...(options?.duration !== undefined ? { duration: options.duration } : {}), }, }); } catch { diff --git a/test/runtime-toast.test.ts b/test/runtime-toast.test.ts new file mode 100644 index 00000000..89e4a14e --- /dev/null +++ b/test/runtime-toast.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from "vitest"; +import { showRuntimeToast } from "../lib/runtime/toast.js"; + +describe("showRuntimeToast", () => { + it("passes a zero duration through to the TUI toast payload", async () => { + const showToast = vi.fn(async () => {}); + await showRuntimeToast( + { tui: { showToast } }, + "Saved", + "info", + { duration: 0 }, + ); + expect(showToast).toHaveBeenCalledWith({ + body: { message: "Saved", variant: "info", duration: 0 }, + }); + }); + + it("silently ignores missing TUI clients", async () => { + await expect(showRuntimeToast({}, "Saved")).resolves.toBeUndefined(); + }); +}); From fdfe2a4ef3c9b688eb33d5816e2f43430cb92561 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 00:49:51 +0800 Subject: [PATCH 178/376] fix: resolve vendor provenance paths from script --- scripts/verify-vendor-provenance.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/verify-vendor-provenance.mjs b/scripts/verify-vendor-provenance.mjs index ccf075c7..46cfbc73 100644 --- a/scripts/verify-vendor-provenance.mjs +++ b/scripts/verify-vendor-provenance.mjs @@ -24,7 +24,7 @@ for (const component of manifest.components) { if (!file?.path || !file?.sha256) { throw new Error(`Invalid file provenance entry in ${component.name}`); } - const content = await readFile(file.path); + const content = await readFile(new URL(`../${file.path}`, import.meta.url)); const actual = createHash("sha256").update(content).digest("hex"); if (actual !== file.sha256) { throw new Error( From 877dedd9340165b2de7c3a5ddcfd8aec7f6a9969 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 00:50:33 +0800 Subject: [PATCH 179/376] ci: move script typecheck before tests --- .github/workflows/pr-ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index f5ba8e5a..7189c42f 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -44,6 +44,9 @@ jobs: - name: Run ESLint run: npm run lint + - name: Typecheck scripts + run: npm run typecheck:scripts + - name: Run tests run: npm test @@ -56,9 +59,6 @@ jobs: - name: Verify vendor provenance run: npm run vendor:verify - - name: Typecheck scripts - run: npm run typecheck:scripts - node22-smoke: name: Node 22 Smoke runs-on: ubuntu-latest From 2ede713f8a2ea9aedd1e5edb0e9d2fb770c2312a Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 00:56:03 +0800 Subject: [PATCH 180/376] refactor: extract storage record utils --- lib/storage.ts | 10 +--------- lib/storage/record-utils.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 9 deletions(-) create mode 100644 lib/storage/record-utils.ts diff --git a/lib/storage.ts b/lib/storage.ts index f2e7238f..64e2cbe6 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -27,6 +27,7 @@ import { collectNamedBackups, type NamedBackupSummary, } from "./storage/named-backups.js"; +import { clampIndex, isRecord } from "./storage/record-utils.js"; import { buildRestoreAssessment, collectBackupMetadata, @@ -958,15 +959,6 @@ export function deduplicateAccountsByEmail< return deduplicateAccountsByIdentity(accounts); } -function isRecord(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value); -} - -function clampIndex(index: number, length: number): number { - if (length <= 0) return 0; - return Math.max(0, Math.min(index, length - 1)); -} - function extractActiveAccountRef( accounts: unknown[], activeIndex: number, diff --git a/lib/storage/record-utils.ts b/lib/storage/record-utils.ts new file mode 100644 index 00000000..746d0242 --- /dev/null +++ b/lib/storage/record-utils.ts @@ -0,0 +1,8 @@ +export function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +export function clampIndex(index: number, length: number): number { + if (length <= 0) return 0; + return Math.max(0, Math.min(index, length - 1)); +} From 7863a19203fa070cb526d1e13773c9d9777578ff Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 00:59:09 +0800 Subject: [PATCH 181/376] fix: cover mjs runtime scripts in validation --- eslint.config.js | 4 ++-- package.json | 6 +++--- scripts/check-pack-budget.mjs | 1 + tsconfig.scripts.json | 3 ++- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 96b3dac6..4f427e52 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,7 +3,7 @@ import tsparser from "@typescript-eslint/parser"; export default [ { - ignores: ["dist/**", "coverage/**", "node_modules/**", "winston/**", ".tmp*/**", "vendor/**", "*.cjs", "*.mjs"], + ignores: ["dist/**", "coverage/**", "node_modules/**", "winston/**", ".tmp*/**", "vendor/**", "*.cjs", "*.mjs", "!scripts/**/*.mjs"], }, { files: ["index.ts", "lib/**/*.ts"], @@ -40,7 +40,7 @@ export default [ }, }, { - files: ["scripts/**/*.js"], + files: ["scripts/**/*.js", "scripts/**/*.mjs"], languageOptions: { ecmaVersion: "latest", sourceType: "module", diff --git a/package.json b/package.json index 24e9114b..9b951bac 100644 --- a/package.json +++ b/package.json @@ -69,10 +69,10 @@ "typecheck:scripts": "tsc -p tsconfig.scripts.json", "lint": "npm run lint:ts && npm run lint:scripts", "lint:ts": "eslint . --ext .ts", - "lint:scripts": "eslint scripts --ext .js", + "lint:scripts": "eslint scripts --ext .js,.mjs", "lint:fix": "npm run lint:ts:fix && npm run lint:scripts:fix", "lint:ts:fix": "eslint . --ext .ts --fix", - "lint:scripts:fix": "eslint scripts --ext .js --fix", + "lint:scripts:fix": "eslint scripts --ext .js,.mjs --fix", "test": "vitest run", "test:watch": "vitest", "test:ui": "vitest --ui", @@ -118,7 +118,7 @@ "*.ts": [ "eslint --max-warnings=0 --fix --no-warn-ignored" ], - "scripts/**/*.js": [ + "scripts/**/*.{js,mjs}": [ "eslint --max-warnings=0 --fix --no-warn-ignored" ] }, diff --git a/scripts/check-pack-budget.mjs b/scripts/check-pack-budget.mjs index 0ea0148a..ae2d90b2 100644 --- a/scripts/check-pack-budget.mjs +++ b/scripts/check-pack-budget.mjs @@ -20,6 +20,7 @@ const FORBIDDEN_PREFIXES = [ ".github/", "test/", "src/", + "lib/", "tmp/", ".tmp/", ".codex/", diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json index 2b361288..fbd91434 100644 --- a/tsconfig.scripts.json +++ b/tsconfig.scripts.json @@ -10,6 +10,7 @@ "scripts/codex-multi-auth.js", "scripts/install-codex-auth.js", "scripts/install-codex-auth-utils.js", - "scripts/check-pack-budget.mjs" + "scripts/check-pack-budget.mjs", + "scripts/verify-vendor-provenance.mjs" ] } From 30a7e0e090dafa58d8932351796828e4d9c1a5d4 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 00:59:09 +0800 Subject: [PATCH 182/376] fix: lint and guard packed scripts --- eslint.config.js | 4 ++-- package.json | 6 +++--- scripts/check-pack-budget.mjs | 1 + 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 96b3dac6..4f427e52 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,7 +3,7 @@ import tsparser from "@typescript-eslint/parser"; export default [ { - ignores: ["dist/**", "coverage/**", "node_modules/**", "winston/**", ".tmp*/**", "vendor/**", "*.cjs", "*.mjs"], + ignores: ["dist/**", "coverage/**", "node_modules/**", "winston/**", ".tmp*/**", "vendor/**", "*.cjs", "*.mjs", "!scripts/**/*.mjs"], }, { files: ["index.ts", "lib/**/*.ts"], @@ -40,7 +40,7 @@ export default [ }, }, { - files: ["scripts/**/*.js"], + files: ["scripts/**/*.js", "scripts/**/*.mjs"], languageOptions: { ecmaVersion: "latest", sourceType: "module", diff --git a/package.json b/package.json index edb6d052..2e988f7f 100644 --- a/package.json +++ b/package.json @@ -68,10 +68,10 @@ "typecheck": "tsc --noEmit", "lint": "npm run lint:ts && npm run lint:scripts", "lint:ts": "eslint . --ext .ts", - "lint:scripts": "eslint scripts --ext .js", + "lint:scripts": "eslint scripts --ext .js,.mjs", "lint:fix": "npm run lint:ts:fix && npm run lint:scripts:fix", "lint:ts:fix": "eslint . --ext .ts --fix", - "lint:scripts:fix": "eslint scripts --ext .js --fix", + "lint:scripts:fix": "eslint scripts --ext .js,.mjs --fix", "test": "vitest run", "test:watch": "vitest", "test:ui": "vitest --ui", @@ -116,7 +116,7 @@ "*.ts": [ "eslint --max-warnings=0 --fix --no-warn-ignored" ], - "scripts/**/*.js": [ + "scripts/**/*.{js,mjs}": [ "eslint --max-warnings=0 --fix --no-warn-ignored" ] }, diff --git a/scripts/check-pack-budget.mjs b/scripts/check-pack-budget.mjs index 0ea0148a..ae2d90b2 100644 --- a/scripts/check-pack-budget.mjs +++ b/scripts/check-pack-budget.mjs @@ -20,6 +20,7 @@ const FORBIDDEN_PREFIXES = [ ".github/", "test/", "src/", + "lib/", "tmp/", ".tmp/", ".codex/", From 5d4a1b92a237948273bd2e12a929045b5014ef41 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 00:59:09 +0800 Subject: [PATCH 183/376] fix: block lib sources from package output --- scripts/check-pack-budget.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/check-pack-budget.mjs b/scripts/check-pack-budget.mjs index 3659724e..be1517ef 100644 --- a/scripts/check-pack-budget.mjs +++ b/scripts/check-pack-budget.mjs @@ -20,6 +20,7 @@ const FORBIDDEN_PREFIXES = [ ".github/", "test/", "src/", + "lib/", "tmp", ".tmp/", ".codex/", From b1f9d1f794d610fc25064646ff4774e021c2c6b3 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 00:59:09 +0800 Subject: [PATCH 184/376] test: cover json explain report mode --- test/codex-manager-cli.test.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index c3faca21..0aa9a411 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -783,6 +783,37 @@ describe("codex manager cli commands", () => { ); }); + it("keeps explain output out of json mode", async () => { + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + accounts: [ + { + email: "one@example.com", + refreshToken: "token-1", + addedAt: Date.now(), + lastUsed: Date.now(), + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli([ + "auth", + "report", + "--json", + "--explain", + ]); + + expect(exitCode).toBe(0); + const payload = String(logSpy.mock.calls[0]?.[0]); + expect(payload).toContain('"forecast"'); + expect(payload).not.toContain("Account 1:"); + expect(payload).not.toContain("Refresh failure:"); + }); + it("prints populated account status for auth status", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValueOnce({ From 30d509b8016603eee6df03eddb0c41d84555cfa7 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 01:00:27 +0800 Subject: [PATCH 185/376] refactor: route runtime toast helper directly --- index.ts | 40 +++++++++++++++++----------------------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/index.ts b/index.ts index cd3eb9fb..447b4639 100644 --- a/index.ts +++ b/index.ts @@ -306,12 +306,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { MODEL_FAMILIES, }); - const showToast = async ( - message: string, - variant: "info" | "success" | "warning" | "error" = "success", - options?: { title?: string; duration?: number }, - ): Promise => showRuntimeToast(client, message, variant, options); - const hydrateEmails = async ( storage: AccountStorageV3 | null, ): Promise => { @@ -630,7 +624,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { await reloadAccountManagerFromDisk(); } - await showToast(`Switched to account ${index + 1}`, "info"); + await showRuntimeToast(client, `Switched to account ${index + 1}`, "info"); } } } catch (error) { @@ -826,7 +820,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { : null; checkAndNotify(async (message, variant) => { - await showToast(message, variant); + await showRuntimeToast(client, message, variant); }).catch((err) => { logDebug( `Update check failed: ${err instanceof Error ? err.message : String(err)}`, @@ -1039,7 +1033,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const remaining = Math.max(0, endTime - Date.now()); const waitLabel = formatWaitTime(remaining); - await showToast( + await showRuntimeToast(client, `${message} (${waitLabel} remaining)`, "warning", { @@ -1206,7 +1200,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { accountManager.removeAccount(account); sessionAffinityStore?.reindexAfterRemoval(removedIndex); accountManager.saveToDiskDebounced(); - await showToast( + await showRuntimeToast(client, `Removed ${accountLabel} after ${failures} consecutive auth failures. Run 'codex login' to re-add.`, "error", { duration: toastDurationMs * 2 }, @@ -1298,7 +1292,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { account, account.index, ); - await showToast( + await showRuntimeToast(client, `Using ${accountLabel} (${account.index + 1}/${accountCount})`, "info", ); @@ -1677,7 +1671,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { fallbackReason: "unsupported-model-entitlement", }, ); - await showToast( + await showRuntimeToast(client, `Model ${previousModel} is not available for this account. Retrying with ${model}.`, "warning", { duration: toastDurationMs }, @@ -1710,7 +1704,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { fallbackReason: "unsupported-model-entitlement", }, ); - await showToast( + await showRuntimeToast(client, `Model ${blockedModel} is not available for this account. Strict policy blocked automatic fallback.`, "warning", { duration: toastDurationMs }, @@ -1805,7 +1799,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const newWorkspaceName = nextWorkspace.name ?? nextWorkspace.id; - await showToast( + await showRuntimeToast(client, `Workspace ${workspaceName} disabled. Switched to ${newWorkspaceName}.`, "warning", { duration: toastDurationMs }, @@ -1830,7 +1824,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ); accountManager.saveToDiskDebounced(); - await showToast( + await showRuntimeToast(client, `All workspaces disabled for account ${account.index + 1}. Switching to another account.`, "warning", { duration: toastDurationMs }, @@ -1869,7 +1863,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const errorType = detectErrorType(errorBody); const toastContent = getRecoveryToastContent(errorType); - await showToast( + await showRuntimeToast(client, `${toastContent.title}: ${toastContent.message}`, "warning", { duration: toastDurationMs }, @@ -1968,7 +1962,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { rateLimitToastDebounceMs, ) ) { - await showToast( + await showRuntimeToast(client, `Rate limited. Retrying in ${waitLabel} (attempt ${attempt})...`, "warning", { duration: toastDurationMs }, @@ -2011,7 +2005,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { rateLimitToastDebounceMs, ) ) { - await showToast( + await showRuntimeToast(client, `Rate limited. Switching accounts (retry in ${waitLabel}).`, "warning", { duration: toastDurationMs }, @@ -2361,7 +2355,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { logWarn( `Empty response received (attempt ${emptyResponseRetries}/${emptyResponseMaxRetries}). Retrying...`, ); - await showToast( + await showRuntimeToast(client, `Empty response. Retrying (${emptyResponseRetries}/${emptyResponseMaxRetries})...`, "warning", { duration: toastDurationMs }, @@ -3547,7 +3541,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { logError( `[${PLUGIN_NAME}] Failed to persist account: [${errorCode}] ${(err as Error)?.message ?? String(err)}`, ); - await showToast(hint, "error", { + await showRuntimeToast(client, hint, "error", { title: "Account Persistence Failed", duration: 10000, }); @@ -3631,7 +3625,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } accounts.push(resolved); - await showToast( + await showRuntimeToast(client, `Account ${accounts.length} authenticated`, "success", ); @@ -3651,7 +3645,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { logError( `[${PLUGIN_NAME}] Failed to persist account: [${errorCode}] ${(err as Error)?.message ?? String(err)}`, ); - await showToast(hint, "error", { + await showRuntimeToast(client, hint, "error", { title: "Account Persistence Failed", duration: 10000, }); @@ -3738,7 +3732,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { logError( `[${PLUGIN_NAME}] Failed to persist account: [${errorCode}] ${(err as Error)?.message ?? String(err)}`, ); - await showToast(hint, "error", { + await showRuntimeToast(client, hint, "error", { title: "Account Persistence Failed", duration: 10000, }); From 6f2ed0dceba32ea30fcc539b68a960efb7281335 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 01:02:09 +0800 Subject: [PATCH 186/376] test: cover runtime auth helper paths --- test/runtime-auth-facade.test.ts | 45 +++++++++++++++++++++++++++++ test/runtime-verify-flagged.test.ts | 36 +++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 test/runtime-auth-facade.test.ts create mode 100644 test/runtime-verify-flagged.test.ts diff --git a/test/runtime-auth-facade.test.ts b/test/runtime-auth-facade.test.ts new file mode 100644 index 00000000..ed858437 --- /dev/null +++ b/test/runtime-auth-facade.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; +import { createPersistAccounts, runRuntimeOAuthFlow } from "../lib/runtime/auth-facade.js"; + +describe("runRuntimeOAuthFlow", () => { + it("prefixes debug and warn logs with the plugin name", async () => { + const logDebug = vi.fn(); + const logWarn = vi.fn(); + await runRuntimeOAuthFlow(true, { + runOAuthBrowserFlow: vi.fn(async (input) => { + input.logDebug("debug message"); + input.logWarn("warn message"); + return { type: "failed", reason: "cancelled" }; + }), + manualModeLabel: "manual", + logInfo: vi.fn(), + logDebug, + logWarn, + pluginName: "codex-multi-auth", + }); + expect(logDebug).toHaveBeenCalledWith("[codex-multi-auth] debug message"); + expect(logWarn).toHaveBeenCalledWith("[codex-multi-auth] warn message"); + }); +}); + +describe("createPersistAccounts", () => { + it("forwards persist dependencies and replaceAll flag", async () => { + const persistAccountPool = vi.fn(async () => {}); + const persistAccounts = createPersistAccounts({ + persistAccountPool, + withAccountStorageTransaction: vi.fn(), + extractAccountId: vi.fn(), + extractAccountEmail: vi.fn(), + sanitizeEmail: vi.fn(), + findMatchingAccountIndex: vi.fn(), + MODEL_FAMILIES: ["codex"], + }); + const results = [{ refreshToken: "r1" }] as never[]; + await persistAccounts(results, true); + expect(persistAccountPool).toHaveBeenCalledWith( + results, + true, + expect.objectContaining({ MODEL_FAMILIES: ["codex"] }), + ); + }); +}); diff --git a/test/runtime-verify-flagged.test.ts b/test/runtime-verify-flagged.test.ts new file mode 100644 index 00000000..92079d61 --- /dev/null +++ b/test/runtime-verify-flagged.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from "vitest"; +import { verifyRuntimeFlaggedAccounts } from "../lib/runtime/verify-flagged.js"; + +describe("verifyRuntimeFlaggedAccounts", () => { + it("restores accounts from Codex CLI cache and preserves the remainder", async () => { + const persistAccounts = vi.fn(async () => {}); + const saveFlaggedAccounts = vi.fn(async () => {}); + const showLine = vi.fn(); + await verifyRuntimeFlaggedAccounts({ + loadFlaggedAccounts: async () => ({ + version: 1, + accounts: [ + { email: "cached@example.com", refreshToken: "cached-refresh", addedAt: 1, lastUsed: 1 }, + { email: "flagged@example.com", refreshToken: "flagged-refresh", addedAt: 1, lastUsed: 1 }, + ], + }), + lookupCodexCliTokensByEmail: async (email) => + email === "cached@example.com" + ? { accessToken: "access", refreshToken: "new-refresh", expiresAt: Date.now() + 60_000 } + : null, + queuedRefresh: async () => ({ type: "failed", reason: "invalid_grant", message: "refresh failed" }), + resolveAccountSelection: (tokens) => ({ refreshToken: tokens.refresh, accessToken: tokens.access }) as never, + persistAccounts, + invalidateAccountManagerCache: vi.fn(), + saveFlaggedAccounts, + logInfo: vi.fn(), + showLine, + }); + expect(persistAccounts).toHaveBeenCalledTimes(1); + expect(saveFlaggedAccounts).toHaveBeenCalledWith({ + version: 1, + accounts: [expect.objectContaining({ refreshToken: "flagged-refresh" })], + }); + expect(showLine).toHaveBeenCalledWith(expect.stringContaining("RESTORED (Codex CLI cache)")); + }); +}); From e0ee8847157234da9b502ae41889aa9c990d54ac Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 01:02:09 +0800 Subject: [PATCH 187/376] docs: describe storage error helpers --- lib/storage/error-hints.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/storage/error-hints.ts b/lib/storage/error-hints.ts index 04a63bc9..3d406adf 100644 --- a/lib/storage/error-hints.ts +++ b/lib/storage/error-hints.ts @@ -5,6 +5,9 @@ function extractErrorCode(error: unknown): string { return err?.code || "UNKNOWN"; } +/** + * Format a user-facing hint for storage persistence failures based on errno code. + */ export function formatStorageErrorHint(error: unknown, path: string): string { const code = extractErrorCode(error); const isWindows = process.platform === "win32"; @@ -28,6 +31,9 @@ export function formatStorageErrorHint(error: unknown, path: string): string { } } +/** + * Wrap an arbitrary storage failure in a StorageError with a derived hint. + */ export function toStorageError( message: string, error: unknown, From e275548d593fe944b5f4fe2b2d7fd628bb64e0e0 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 01:02:09 +0800 Subject: [PATCH 188/376] ci: add windows script typecheck lane --- .github/workflows/pr-ci.yml | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 7189c42f..26382405 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -44,15 +44,15 @@ jobs: - name: Run ESLint run: npm run lint - - name: Typecheck scripts - run: npm run typecheck:scripts - - name: Run tests run: npm test - name: Build run: npm run build + - name: Typecheck scripts + run: npm run typecheck:scripts + - name: Pack budget check run: npm run pack:check @@ -81,3 +81,23 @@ jobs: - name: Build run: npm run build + + scripts-windows: + name: Script Typecheck (Windows) + runs-on: windows-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Typecheck scripts + run: npm run typecheck:scripts From bb6c523c2a2593022d781de98068c9b73a6857de Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 01:09:12 +0800 Subject: [PATCH 189/376] test: cover pack budget script paths --- scripts/check-pack-budget-lib.js | 97 ++++++++++++++++++++++++++++++++ scripts/check-pack-budget.mjs | 85 ++-------------------------- test/check-pack-budget.test.ts | 80 ++++++++++++++++++++++++++ 3 files changed, 183 insertions(+), 79 deletions(-) create mode 100644 scripts/check-pack-budget-lib.js create mode 100644 test/check-pack-budget.test.ts diff --git a/scripts/check-pack-budget-lib.js b/scripts/check-pack-budget-lib.js new file mode 100644 index 00000000..7b2bb600 --- /dev/null +++ b/scripts/check-pack-budget-lib.js @@ -0,0 +1,97 @@ +import { exec } from "node:child_process"; +import { promisify } from "node:util"; + +const execAsync = promisify(exec); + +export const MAX_PACKAGE_SIZE = 8 * 1024 * 1024; +export const REQUIRED_PREFIXES = [ + "dist/", + "assets/", + "config/", + "scripts/", + "vendor/codex-ai-plugin/", + "vendor/codex-ai-sdk/", + "README.md", + "LICENSE", +]; + +export const FORBIDDEN_PREFIXES = [ + ".github/", + "test/", + "src/", + "lib/", + "tmp/", + ".tmp/", + ".codex/", +]; + +export function normalizePackPath(filePath) { + return filePath.replaceAll("\\", "/"); +} + +export function parsePackMetadata(stdout) { + const packs = JSON.parse(stdout); + if (!Array.isArray(packs) || packs.length === 0) { + throw new Error("npm pack --dry-run --json returned no package metadata"); + } + + const pack = packs[0]; + if (!pack || !Array.isArray(pack.files)) { + throw new Error("npm pack metadata did not include file list"); + } + + const packageSize = typeof pack.size === "number" ? pack.size : 0; + if (packageSize <= 0) { + throw new Error("npm pack metadata did not include a valid package size"); + } + + const paths = pack.files + .map((file) => file?.path) + .filter((value) => typeof value === "string") + .map((value) => normalizePackPath(value)); + + return { packageSize, paths }; +} + +export function validatePackMetadata({ packageSize, paths }) { + if (packageSize > MAX_PACKAGE_SIZE) { + throw new Error( + `Packed tarball is too large: ${packageSize} bytes (max ${MAX_PACKAGE_SIZE})`, + ); + } + + for (const forbidden of FORBIDDEN_PREFIXES) { + const leaked = paths.find( + (path) => path === forbidden || path.startsWith(forbidden), + ); + if (leaked) { + throw new Error(`Forbidden file leaked into package: ${leaked}`); + } + } + + for (const required of REQUIRED_PREFIXES) { + const present = paths.some( + (path) => path === required || path.startsWith(required), + ); + if (!present) { + throw new Error( + `Required package content missing from npm pack output: ${required}`, + ); + } + } + + return `Pack budget ok: ${packageSize} bytes across ${paths.length} files`; +} + +export async function runPackBudgetCheck(deps = {}) { + const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; + const runExec = deps.execAsync ?? execAsync; + const log = deps.log ?? console.log; + const { stdout } = await runExec(`${npmCommand} pack --dry-run --json`, { + windowsHide: true, + maxBuffer: 10 * 1024 * 1024, + }); + const summary = validatePackMetadata(parsePackMetadata(stdout)); + log(summary); + return summary; +} diff --git a/scripts/check-pack-budget.mjs b/scripts/check-pack-budget.mjs index ae2d90b2..a1d05850 100644 --- a/scripts/check-pack-budget.mjs +++ b/scripts/check-pack-budget.mjs @@ -1,83 +1,10 @@ #!/usr/bin/env node -import { exec } from "node:child_process"; -import { promisify } from "node:util"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runPackBudgetCheck } from "./check-pack-budget-lib.js"; -const execAsync = promisify(exec); +export { runPackBudgetCheck } from "./check-pack-budget-lib.js"; -const MAX_PACKAGE_SIZE = 8 * 1024 * 1024; -const REQUIRED_PREFIXES = [ - "dist/", - "assets/", - "config/", - "scripts/", - "vendor/codex-ai-plugin/", - "vendor/codex-ai-sdk/", - "README.md", - "LICENSE", -]; - -const FORBIDDEN_PREFIXES = [ - ".github/", - "test/", - "src/", - "lib/", - "tmp/", - ".tmp/", - ".codex/", -]; - -const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; - -const { stdout } = await execAsync(`${npmCommand} pack --dry-run --json`, { - windowsHide: true, - maxBuffer: 10 * 1024 * 1024, -}); - -const packs = JSON.parse(stdout); -if (!Array.isArray(packs) || packs.length === 0) { - throw new Error("npm pack --dry-run --json returned no package metadata"); -} - -const pack = packs[0]; -if (!pack || !Array.isArray(pack.files)) { - throw new Error("npm pack metadata did not include file list"); -} - -const packageSize = typeof pack.size === "number" ? pack.size : 0; -if (packageSize <= 0) { - throw new Error("npm pack metadata did not include a valid package size"); +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await runPackBudgetCheck(); } - -if (packageSize > MAX_PACKAGE_SIZE) { - throw new Error( - `Packed tarball is too large: ${packageSize} bytes (max ${MAX_PACKAGE_SIZE})`, - ); -} - -const paths = pack.files - .map((file) => file.path) - .filter((value) => typeof value === "string"); - -for (const forbidden of FORBIDDEN_PREFIXES) { - const leaked = paths.find( - (path) => path === forbidden || path.startsWith(forbidden), - ); - if (leaked) { - throw new Error(`Forbidden file leaked into package: ${leaked}`); - } -} - -for (const required of REQUIRED_PREFIXES) { - const present = paths.some( - (path) => path === required || path.startsWith(required), - ); - if (!present) { - throw new Error( - `Required package content missing from npm pack output: ${required}`, - ); - } -} - -console.log( - `Pack budget ok: ${packageSize} bytes across ${paths.length} files`, -); diff --git a/test/check-pack-budget.test.ts b/test/check-pack-budget.test.ts new file mode 100644 index 00000000..e468c90e --- /dev/null +++ b/test/check-pack-budget.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from "vitest"; +import { + parsePackMetadata, + runPackBudgetCheck, + validatePackMetadata, +} from "../scripts/check-pack-budget-lib.js"; + +describe("parsePackMetadata", () => { + it("normalizes Windows-style paths from npm pack output", () => { + const result = parsePackMetadata( + JSON.stringify([ + { + size: 123, + files: [ + { path: String.raw`dist\index.js` }, + { path: String.raw`vendor\codex-ai-plugin\index.js` }, + ], + }, + ]), + ); + expect(result).toEqual({ + packageSize: 123, + paths: ["dist/index.js", "vendor/codex-ai-plugin/index.js"], + }); + }); + + it("throws when npm pack returns no package metadata", () => { + expect(() => parsePackMetadata("[]")).toThrow(/no package metadata/); + }); +}); + +describe("validatePackMetadata", () => { + it("rejects forbidden lib sources in the packed file list", () => { + expect(() => + validatePackMetadata({ + packageSize: 123, + paths: [ + "dist/index.js", + "assets/logo.svg", + "config/default.json", + "scripts/codex.js", + "vendor/codex-ai-plugin/index.js", + "vendor/codex-ai-sdk/index.js", + "README.md", + "LICENSE", + "lib/storage.js", + ], + }), + ).toThrow(/Forbidden file leaked into package: lib\/storage\.js/); + }); +}); + +describe("runPackBudgetCheck", () => { + it("logs the pack summary for valid metadata", async () => { + const log = vi.fn(); + await expect( + runPackBudgetCheck({ + execAsync: vi.fn(async () => ({ + stdout: JSON.stringify([ + { + size: 321, + files: [ + { path: "dist/index.js" }, + { path: "assets/logo.svg" }, + { path: "config/default.json" }, + { path: "scripts/codex.js" }, + { path: "vendor/codex-ai-plugin/index.js" }, + { path: "vendor/codex-ai-sdk/index.js" }, + { path: "README.md" }, + { path: "LICENSE" }, + ], + }, + ]), + })), + log, + }), + ).resolves.toBe("Pack budget ok: 321 bytes across 8 files"); + expect(log).toHaveBeenCalledWith("Pack budget ok: 321 bytes across 8 files"); + }); +}); From ed1ffd931d0b38b55749ad4a28304764a934e0cd Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 01:13:23 +0800 Subject: [PATCH 190/376] refactor: extract statusline order helper --- lib/codex-manager/settings-hub.ts | 22 ++++------------------ lib/codex-manager/statusline-order.ts | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 18 deletions(-) create mode 100644 lib/codex-manager/statusline-order.ts diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index f0e9b108..4f26e7b6 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -72,6 +72,10 @@ import { warnPersistFailure, } from "./settings-persist-utils.js"; import { withQueuedRetry } from "./settings-write-queue.js"; +import { reorderStatuslineField } from "./statusline-order.js"; + +const reorderField = reorderStatuslineField; + import { promptStatuslineSettingsPanel } from "./statusline-settings-panel.js"; import { promptThemeSettingsPanel } from "./theme-settings-panel.js"; @@ -609,24 +613,6 @@ async function configureDashboardDisplaySettings( return merged; } -function reorderField( - fields: DashboardStatuslineField[], - key: DashboardStatuslineField, - direction: -1 | 1, -): DashboardStatuslineField[] { - const index = fields.indexOf(key); - if (index < 0) return fields; - const target = index + direction; - if (target < 0 || target >= fields.length) return fields; - const next = [...fields]; - const current = next[index]; - const swap = next[target]; - if (!current || !swap) return fields; - next[index] = swap; - next[target] = current; - return next; -} - async function promptStatuslineSettings( initial: DashboardDisplaySettings, ): Promise { diff --git a/lib/codex-manager/statusline-order.ts b/lib/codex-manager/statusline-order.ts new file mode 100644 index 00000000..e1763313 --- /dev/null +++ b/lib/codex-manager/statusline-order.ts @@ -0,0 +1,19 @@ +import type { DashboardStatuslineField } from "../dashboard-settings.js"; + +export function reorderStatuslineField( + fields: DashboardStatuslineField[], + key: DashboardStatuslineField, + direction: -1 | 1, +): DashboardStatuslineField[] { + const index = fields.indexOf(key); + if (index < 0) return fields; + const target = index + direction; + if (target < 0 || target >= fields.length) return fields; + const next = [...fields]; + const current = next[index]; + const swap = next[target]; + if (!current || !swap) return fields; + next[index] = swap; + next[target] = current; + return next; +} From daa9c11f589896fd5b6350b0ea3617809bc6038f Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 01:24:37 +0800 Subject: [PATCH 191/376] fix: report flagged verification errors --- lib/runtime/verify-flagged.ts | 1 + test/runtime-verify-flagged.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/lib/runtime/verify-flagged.ts b/lib/runtime/verify-flagged.ts index efd3dec5..55c528ae 100644 --- a/lib/runtime/verify-flagged.ts +++ b/lib/runtime/verify-flagged.ts @@ -126,6 +126,7 @@ export async function verifyRuntimeFlaggedAccounts(deps: { ); } catch (error) { const message = error instanceof Error ? error.message : String(error); + deps.logError?.(`Failed to verify flagged account ${label}: ${message}`); deps.showLine( `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: ERROR (${message.slice(0, 120)})`, ); diff --git a/test/runtime-verify-flagged.test.ts b/test/runtime-verify-flagged.test.ts index 92079d61..c79c7eeb 100644 --- a/test/runtime-verify-flagged.test.ts +++ b/test/runtime-verify-flagged.test.ts @@ -33,4 +33,33 @@ describe("verifyRuntimeFlaggedAccounts", () => { }); expect(showLine).toHaveBeenCalledWith(expect.stringContaining("RESTORED (Codex CLI cache)")); }); + + it("logs verification failures through logError and keeps the account flagged", async () => { + const logError = vi.fn(); + const saveFlaggedAccounts = vi.fn(async () => {}); + await verifyRuntimeFlaggedAccounts({ + loadFlaggedAccounts: async () => ({ + version: 1, + accounts: [{ email: "broken@example.com", refreshToken: "broken-refresh", addedAt: 1, lastUsed: 1 }], + }), + lookupCodexCliTokensByEmail: async () => { + throw new Error("cache unavailable"); + }, + queuedRefresh: async () => ({ type: "failed", reason: "invalid_grant" }), + resolveAccountSelection: () => ({}) as never, + persistAccounts: vi.fn(async () => {}), + invalidateAccountManagerCache: vi.fn(), + saveFlaggedAccounts, + logInfo: vi.fn(), + logError, + showLine: vi.fn(), + }); + expect(logError).toHaveBeenCalledWith( + expect.stringContaining("Failed to verify flagged account broken@example.com: cache unavailable"), + ); + expect(saveFlaggedAccounts).toHaveBeenCalledWith({ + version: 1, + accounts: [expect.objectContaining({ refreshToken: "broken-refresh", lastError: "cache unavailable" })], + }); + }); }); From 6fb135e875911fdee10cf66c5361548992827f30 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 01:24:37 +0800 Subject: [PATCH 192/376] fix: stabilize config explain reporting --- lib/config.ts | 7 ++++--- test/codex-manager-cli.test.ts | 15 +++++++++++++++ test/config-explain.test.ts | 15 +++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 test/config-explain.test.ts diff --git a/lib/config.ts b/lib/config.ts index 300aa1bb..a30140cd 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -1436,9 +1436,10 @@ export function getPluginConfigExplainReport(): ConfigExplainReport { const stored = resolveStoredPluginConfigRecord(); const storedRecord = stored.record ?? null; const entries = CONFIG_EXPLAIN_ENTRIES.map((entry) => { - const source: ConfigExplainSource = entry.envNames.some( - (name) => process.env[name] !== undefined, - ) + const source: ConfigExplainSource = entry.envNames.some((name) => { + const value = process.env[name]; + return typeof value === "string" && value.trim().length > 0; + }) ? "env" : storedRecord && Object.hasOwn(storedRecord, entry.key) ? stored.storageKind diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 0d361016..5b83f1b5 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -19,6 +19,7 @@ const loadQuotaCacheMock = vi.fn(); const saveQuotaCacheMock = vi.fn(); const loadPluginConfigMock = vi.fn(); const savePluginConfigMock = vi.fn(); +const getPluginConfigExplainReportMock = vi.fn(); const selectMock = vi.fn(); const confirmMock = vi.fn(async () => true); const planOcChatgptSyncMock = vi.fn(); @@ -181,6 +182,7 @@ vi.mock("../lib/config.js", async () => { ...(actual as Record), loadPluginConfig: loadPluginConfigMock, savePluginConfig: savePluginConfigMock, + getPluginConfigExplainReport: getPluginConfigExplainReportMock, }; }); @@ -564,6 +566,19 @@ describe("codex manager cli commands", () => { }); loadPluginConfigMock.mockReturnValue({}); savePluginConfigMock.mockResolvedValue(undefined); + getPluginConfigExplainReportMock.mockReturnValue({ + configPath: "/mock/settings.json", + storageKind: "unified", + entries: [ + { + key: "codexMode", + value: true, + defaultValue: true, + source: "default", + envNames: ["CODEX_MODE"], + }, + ], + }); selectMock.mockResolvedValue(undefined); getNamedBackupsMock.mockResolvedValue([]); restoreTTYDescriptors(); diff --git a/test/config-explain.test.ts b/test/config-explain.test.ts new file mode 100644 index 00000000..c5d85d73 --- /dev/null +++ b/test/config-explain.test.ts @@ -0,0 +1,15 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { getPluginConfigExplainReport } from "../lib/config.js"; + +describe("getPluginConfigExplainReport", () => { + afterEach(() => { + delete process.env.CODEX_MODE; + }); + + it("treats empty env vars as non-env sources", () => { + process.env.CODEX_MODE = " "; + const report = getPluginConfigExplainReport(); + const entry = report.entries.find((item) => item.key === "codexMode"); + expect(entry?.source).not.toBe("env"); + }); +}); From ae30cfe33c83321267116bfa5986f78285aab94a Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 01:33:29 +0800 Subject: [PATCH 193/376] fix: typecheck runtime script helpers --- scripts/check-pack-budget.mjs | 8 ++++---- scripts/verify-vendor-provenance.mjs | 2 +- tsconfig.scripts.json | 3 ++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/scripts/check-pack-budget.mjs b/scripts/check-pack-budget.mjs index ae2d90b2..96ac0b74 100644 --- a/scripts/check-pack-budget.mjs +++ b/scripts/check-pack-budget.mjs @@ -55,12 +55,12 @@ if (packageSize > MAX_PACKAGE_SIZE) { } const paths = pack.files - .map((file) => file.path) - .filter((value) => typeof value === "string"); + .map((/** @type {{ path?: unknown }} */ file) => file.path) + .filter((/** @type {unknown} */ value) => typeof value === "string"); for (const forbidden of FORBIDDEN_PREFIXES) { const leaked = paths.find( - (path) => path === forbidden || path.startsWith(forbidden), + (/** @type {string} */ path) => path === forbidden || path.startsWith(forbidden), ); if (leaked) { throw new Error(`Forbidden file leaked into package: ${leaked}`); @@ -69,7 +69,7 @@ for (const forbidden of FORBIDDEN_PREFIXES) { for (const required of REQUIRED_PREFIXES) { const present = paths.some( - (path) => path === required || path.startsWith(required), + (/** @type {string} */ path) => path === required || path.startsWith(required), ); if (!present) { throw new Error( diff --git a/scripts/verify-vendor-provenance.mjs b/scripts/verify-vendor-provenance.mjs index 46cfbc73..49fc11b4 100644 --- a/scripts/verify-vendor-provenance.mjs +++ b/scripts/verify-vendor-provenance.mjs @@ -35,5 +35,5 @@ for (const component of manifest.components) { } console.log( - `Vendor provenance ok: ${manifest.components.length} component(s), ${manifest.components.reduce((sum, component) => sum + component.files.length, 0)} file(s) verified`, + `Vendor provenance ok: ${manifest.components.length} component(s), ${manifest.components.reduce((/** @type {number} */ sum, /** @type {{ files?: unknown[] }} */ component) => sum + (Array.isArray(component.files) ? component.files.length : 0), 0)} file(s) verified`, ); diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json index fbd91434..95cced6d 100644 --- a/tsconfig.scripts.json +++ b/tsconfig.scripts.json @@ -12,5 +12,6 @@ "scripts/install-codex-auth-utils.js", "scripts/check-pack-budget.mjs", "scripts/verify-vendor-provenance.mjs" - ] + ], + "exclude": [] } From 7f0ddd73c3d7ea3fa1a36a61b159a75bc44a4cee Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 01:33:29 +0800 Subject: [PATCH 194/376] test: preserve runtime toast payload fields --- test/runtime-toast.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/runtime-toast.test.ts b/test/runtime-toast.test.ts index 89e4a14e..eda1281b 100644 --- a/test/runtime-toast.test.ts +++ b/test/runtime-toast.test.ts @@ -2,16 +2,16 @@ import { describe, expect, it, vi } from "vitest"; import { showRuntimeToast } from "../lib/runtime/toast.js"; describe("showRuntimeToast", () => { - it("passes a zero duration through to the TUI toast payload", async () => { + it("preserves variant, title, and zero duration in the TUI toast payload", async () => { const showToast = vi.fn(async () => {}); await showRuntimeToast( { tui: { showToast } }, "Saved", "info", - { duration: 0 }, + { title: "Heads up", duration: 0 }, ); expect(showToast).toHaveBeenCalledWith({ - body: { message: "Saved", variant: "info", duration: 0 }, + body: { message: "Saved", variant: "info", title: "Heads up", duration: 0 }, }); }); From 5a4ee59692df380ff9b549a1478034765823f00d Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 02:25:39 +0800 Subject: [PATCH 195/376] fix: explain config sources from effective resolution --- lib/config.ts | 68 +++++++++++++++++++++++++++++++------ test/config-explain.test.ts | 46 +++++++++++++++++++++---- 2 files changed, 96 insertions(+), 18 deletions(-) diff --git a/lib/config.ts b/lib/config.ts index a30140cd..ce83c708 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -1213,8 +1213,60 @@ type ConfigExplainMeta = { key: keyof PluginConfig; envNames: string[]; getValue: (pluginConfig: PluginConfig) => unknown; + sourceKeys?: (keyof PluginConfig)[]; }; +function withExplainEnvUnset(envNames: string[], run: () => T): T { + const previous = new Map(); + for (const name of envNames) { + previous.set(name, process.env[name]); + delete process.env[name]; + } + try { + return run(); + } finally { + for (const [name, value] of previous) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + } +} + +function configExplainValuesEqual(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function resolveConfigExplainSource( + entry: ConfigExplainMeta, + pluginConfig: PluginConfig, + storedRecord: Partial | null, + storageKind: "unified" | "file" | "none", +): ConfigExplainSource { + const effectiveValue = entry.getValue(pluginConfig); + const noEnvValue = withExplainEnvUnset(entry.envNames, () => entry.getValue(pluginConfig)); + if (!configExplainValuesEqual(effectiveValue, noEnvValue)) { + return "env"; + } + const defaultResolvedValue = withExplainEnvUnset(entry.envNames, () => + entry.getValue({} as PluginConfig), + ); + const storedKeys = entry.sourceKeys ?? [entry.key]; + const hasStoredSource = + storageKind !== "none" && + storedRecord !== null && + storedKeys.some((key) => Object.hasOwn(storedRecord, key)); + if (hasStoredSource && !configExplainValuesEqual(noEnvValue, defaultResolvedValue)) { + return storageKind; + } + if (hasStoredSource && storedKeys.length > 1) { + return storageKind; + } + return "default"; +} + const CONFIG_EXPLAIN_ENTRIES: ConfigExplainMeta[] = [ { key: "codexMode", envNames: ["CODEX_MODE"], getValue: getCodexMode }, { key: "codexTuiV2", envNames: ["CODEX_TUI_V2"], getValue: getCodexTuiV2 }, @@ -1265,6 +1317,7 @@ const CONFIG_EXPLAIN_ENTRIES: ConfigExplainMeta[] = [ "CODEX_AUTH_FALLBACK_UNSUPPORTED_MODEL", ], getValue: getUnsupportedCodexPolicy, + sourceKeys: ["unsupportedCodexPolicy", "fallbackOnUnsupportedCodexModel"], }, { key: "fallbackOnUnsupportedCodexModel", @@ -1273,6 +1326,7 @@ const CONFIG_EXPLAIN_ENTRIES: ConfigExplainMeta[] = [ "CODEX_AUTH_FALLBACK_UNSUPPORTED_MODEL", ], getValue: getFallbackOnUnsupportedCodexModel, + sourceKeys: ["unsupportedCodexPolicy", "fallbackOnUnsupportedCodexModel"], }, { key: "fallbackToGpt52OnUnsupportedGpt53", @@ -1436,20 +1490,12 @@ export function getPluginConfigExplainReport(): ConfigExplainReport { const stored = resolveStoredPluginConfigRecord(); const storedRecord = stored.record ?? null; const entries = CONFIG_EXPLAIN_ENTRIES.map((entry) => { - const source: ConfigExplainSource = entry.envNames.some((name) => { - const value = process.env[name]; - return typeof value === "string" && value.trim().length > 0; - }) - ? "env" - : storedRecord && Object.hasOwn(storedRecord, entry.key) - ? stored.storageKind - : "default"; - + const value = entry.getValue(pluginConfig); return { key: entry.key, - value: entry.getValue(pluginConfig), + value, defaultValue: DEFAULT_PLUGIN_CONFIG[entry.key], - source, + source: resolveConfigExplainSource(entry, pluginConfig, storedRecord, stored.storageKind), envNames: entry.envNames, }; }); diff --git a/test/config-explain.test.ts b/test/config-explain.test.ts index c5d85d73..5a5e5a7c 100644 --- a/test/config-explain.test.ts +++ b/test/config-explain.test.ts @@ -1,15 +1,47 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { getPluginConfigExplainReport } from "../lib/config.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { promises as fs } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const loadUnifiedPluginConfigSyncMock = vi.fn(() => null); + +vi.mock("../lib/unified-settings.js", () => ({ + getUnifiedSettingsPath: () => "/mock/unified-settings.json", + loadUnifiedPluginConfigSync: loadUnifiedPluginConfigSyncMock, + saveUnifiedPluginConfig: vi.fn(), +})); describe("getPluginConfigExplainReport", () => { - afterEach(() => { - delete process.env.CODEX_MODE; + afterEach(async () => { + delete process.env.CODEX_AUTH_FAST_SESSION_STRATEGY; + delete process.env.CODEX_MULTI_AUTH_CONFIG_PATH; + loadUnifiedPluginConfigSyncMock.mockReset(); + loadUnifiedPluginConfigSyncMock.mockReturnValue(null); + vi.resetModules(); }); - it("treats empty env vars as non-env sources", () => { - process.env.CODEX_MODE = " "; + it("treats invalid string env values as non-env sources", async () => { + process.env.CODEX_AUTH_FAST_SESSION_STRATEGY = "bogus"; + const { getPluginConfigExplainReport } = await import("../lib/config.js"); const report = getPluginConfigExplainReport(); - const entry = report.entries.find((item) => item.key === "codexMode"); + const entry = report.entries.find((item) => item.key === "fastSessionStrategy"); expect(entry?.source).not.toBe("env"); }); + + it("attributes alias-backed fallback policy values to stored config", async () => { + const configPath = join(tmpdir(), `config-explain-${Date.now()}.json`); + process.env.CODEX_MULTI_AUTH_CONFIG_PATH = configPath; + await fs.writeFile( + configPath, + JSON.stringify({ fallbackOnUnsupportedCodexModel: true }, null, 2), + "utf-8", + ); + const { getPluginConfigExplainReport } = await import("../lib/config.js"); + const report = getPluginConfigExplainReport(); + const policy = report.entries.find((item) => item.key === "unsupportedCodexPolicy"); + const fallback = report.entries.find((item) => item.key === "fallbackOnUnsupportedCodexModel"); + expect(policy?.source).toBe("file"); + expect(fallback?.source).toBe("file"); + await fs.unlink(configPath); + }); }); From 949bc03748e2d616c2018b3b4d03e05b7948a3a8 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 02:33:09 +0800 Subject: [PATCH 196/376] test: expand runtime helper coverage --- test/runtime-account-check.test.ts | 56 +++++++++++++++------------ test/runtime-session-recovery.test.ts | 36 +++++++++++++++++ 2 files changed, 68 insertions(+), 24 deletions(-) create mode 100644 test/runtime-session-recovery.test.ts diff --git a/test/runtime-account-check.test.ts b/test/runtime-account-check.test.ts index f7e63139..a2c2c0cc 100644 --- a/test/runtime-account-check.test.ts +++ b/test/runtime-account-check.test.ts @@ -2,6 +2,34 @@ import { describe, expect, it, vi } from "vitest"; import { runRuntimeAccountCheck } from "../lib/runtime/account-check.js"; describe("runRuntimeAccountCheck", () => { + it("reports when there are no accounts to check", async () => { + const showLine = vi.fn(); + await runRuntimeAccountCheck(false, { + hydrateEmails: async (storage) => storage, + loadAccounts: async () => null, + createEmptyStorage: () => ({ version: 3, accounts: [], activeIndex: 0, activeIndexByFamily: {} }), + loadFlaggedAccounts: async () => ({ version: 1, accounts: [] }), + createAccountCheckWorkingState: () => ({ flaggedStorage: { version: 1, accounts: [] }, removeFromActive: new Set(), storageChanged: false, flaggedChanged: false, ok: 0, errors: 0, disabled: 0 }), + lookupCodexCliTokensByEmail: async () => null, + extractAccountId: () => undefined, + shouldUpdateAccountIdFromToken: () => false, + sanitizeEmail: (email) => email, + extractAccountEmail: () => undefined, + queuedRefresh: async () => ({ type: "failed", reason: "invalid_grant" }), + isRuntimeFlaggableFailure: () => false, + fetchCodexQuotaSnapshot: async () => ({ quotaKey: "codex", limits: {} } as never), + resolveRequestAccountId: () => undefined, + formatCodexQuotaLine: () => "quota", + clampRuntimeActiveIndices: vi.fn(), + MODEL_FAMILIES: ["codex"], + saveAccounts: vi.fn(async () => {}), + invalidateAccountManagerCache: vi.fn(), + saveFlaggedAccounts: vi.fn(async () => {}), + showLine, + }); + expect(showLine).toHaveBeenCalledWith("\nNo accounts to check.\n"); + }); + it("reuses the current time when flagging an invalid refresh token", async () => { const saveFlaggedAccounts = vi.fn(async () => {}); const now = vi.fn(() => 1000 + now.mock.calls.length - 1); @@ -11,42 +39,22 @@ describe("runRuntimeAccountCheck", () => { loadAccounts: async () => ({ version: 3, accounts: [ - { - email: "one@example.com", - refreshToken: "refresh-1", - accessToken: undefined, - addedAt: 1, - lastUsed: 1, - }, + { email: "one@example.com", refreshToken: "refresh-1", accessToken: undefined, addedAt: 1, lastUsed: 1 }, ], activeIndex: 0, activeIndexByFamily: { codex: 0 }, }), createEmptyStorage: () => ({ version: 3, accounts: [], activeIndex: 0, activeIndexByFamily: {} }), loadFlaggedAccounts: async () => ({ version: 1, accounts: [] }), - createAccountCheckWorkingState: (flaggedStorage) => ({ - flaggedStorage, - removeFromActive: new Set(), - storageChanged: false, - flaggedChanged: false, - ok: 0, - errors: 0, - disabled: 0, - }), + createAccountCheckWorkingState: (flaggedStorage) => ({ flaggedStorage, removeFromActive: new Set(), storageChanged: false, flaggedChanged: false, ok: 0, errors: 0, disabled: 0 }), lookupCodexCliTokensByEmail: async () => null, extractAccountId: () => undefined, shouldUpdateAccountIdFromToken: () => false, sanitizeEmail: (email) => email, extractAccountEmail: () => undefined, - queuedRefresh: async () => ({ - type: "failed", - reason: "invalid_grant", - message: "refresh failed", - }), + queuedRefresh: async () => ({ type: "failed", reason: "invalid_grant", message: "refresh failed" }), isRuntimeFlaggableFailure: () => true, - fetchCodexQuotaSnapshot: async () => { - throw new Error("should not probe quota in deep mode"); - }, + fetchCodexQuotaSnapshot: async () => { throw new Error("should not probe quota in deep mode"); }, resolveRequestAccountId: () => undefined, formatCodexQuotaLine: () => "quota", clampRuntimeActiveIndices: vi.fn(), diff --git a/test/runtime-session-recovery.test.ts b/test/runtime-session-recovery.test.ts new file mode 100644 index 00000000..be1ca6c5 --- /dev/null +++ b/test/runtime-session-recovery.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from "vitest"; + +const createSessionRecoveryHookMock = vi.fn(); +vi.mock("../lib/recovery.js", () => ({ + createSessionRecoveryHook: createSessionRecoveryHookMock, +})); + +describe("createRuntimeSessionRecoveryHook", () => { + it("returns null when disabled", async () => { + const { createRuntimeSessionRecoveryHook } = await import("../lib/runtime/session-recovery.js"); + expect( + createRuntimeSessionRecoveryHook({ + enabled: false, + client: {} as never, + directory: "/tmp/recovery", + autoResume: true, + }), + ).toBeNull(); + }); + + it("forwards typed client context when enabled", async () => { + createSessionRecoveryHookMock.mockReturnValueOnce({ handleSessionRecovery: vi.fn() }); + const client = {} as never; + const { createRuntimeSessionRecoveryHook } = await import("../lib/runtime/session-recovery.js"); + createRuntimeSessionRecoveryHook({ + enabled: true, + client, + directory: "/tmp/recovery", + autoResume: false, + }); + expect(createSessionRecoveryHookMock).toHaveBeenCalledWith( + { client, directory: "/tmp/recovery" }, + { sessionRecovery: true, autoResume: false }, + ); + }); +}); From 4d3ec953da724a60e1352e19aabb2784e711408e Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 02:44:51 +0800 Subject: [PATCH 197/376] fix: tighten runtime helper logging and types --- lib/runtime/auth-facade.ts | 2 +- lib/runtime/session-recovery.ts | 3 ++- test/runtime-auth-facade.test.ts | 35 +++++++++++++++++++++++++++++--- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/lib/runtime/auth-facade.ts b/lib/runtime/auth-facade.ts index 1b9a60fd..844a2fa6 100644 --- a/lib/runtime/auth-facade.ts +++ b/lib/runtime/auth-facade.ts @@ -22,7 +22,7 @@ export async function runRuntimeOAuthFlow( return deps.runOAuthBrowserFlow({ forceNewLogin, manualModeLabel: deps.manualModeLabel, - logInfo: deps.logInfo, + logInfo: (message) => deps.logInfo(`[${deps.pluginName}] ${message}`), logDebug: (message) => deps.logDebug(`[${deps.pluginName}] ${message}`), logWarn: (message) => deps.logWarn(`[${deps.pluginName}] ${message}`), }); diff --git a/lib/runtime/session-recovery.ts b/lib/runtime/session-recovery.ts index a6e709ae..52f07a76 100644 --- a/lib/runtime/session-recovery.ts +++ b/lib/runtime/session-recovery.ts @@ -1,8 +1,9 @@ +import type { PluginInput } from "@codex-ai/plugin"; import { createSessionRecoveryHook } from "../recovery.js"; export function createRuntimeSessionRecoveryHook(deps: { enabled: boolean; - client: unknown; + client: PluginInput["client"]; directory: string; autoResume: boolean; }) { diff --git a/test/runtime-auth-facade.test.ts b/test/runtime-auth-facade.test.ts index ed858437..cce8b32c 100644 --- a/test/runtime-auth-facade.test.ts +++ b/test/runtime-auth-facade.test.ts @@ -1,22 +1,29 @@ import { describe, expect, it, vi } from "vitest"; -import { createPersistAccounts, runRuntimeOAuthFlow } from "../lib/runtime/auth-facade.js"; +import { + createAccountManagerReloader, + createPersistAccounts, + runRuntimeOAuthFlow, +} from "../lib/runtime/auth-facade.js"; describe("runRuntimeOAuthFlow", () => { - it("prefixes debug and warn logs with the plugin name", async () => { + it("prefixes info, debug and warn logs with the plugin name", async () => { + const logInfo = vi.fn(); const logDebug = vi.fn(); const logWarn = vi.fn(); await runRuntimeOAuthFlow(true, { runOAuthBrowserFlow: vi.fn(async (input) => { + input.logInfo("info message"); input.logDebug("debug message"); input.logWarn("warn message"); return { type: "failed", reason: "cancelled" }; }), manualModeLabel: "manual", - logInfo: vi.fn(), + logInfo, logDebug, logWarn, pluginName: "codex-multi-auth", }); + expect(logInfo).toHaveBeenCalledWith("[codex-multi-auth] info message"); expect(logDebug).toHaveBeenCalledWith("[codex-multi-auth] debug message"); expect(logWarn).toHaveBeenCalledWith("[codex-multi-auth] warn message"); }); @@ -43,3 +50,25 @@ describe("createPersistAccounts", () => { ); }); }); + +describe("createAccountManagerReloader", () => { + it("forwards auth fallback and current reload state", async () => { + const reloadRuntimeAccountManager = vi.fn(async () => "manager"); + const reloader = createAccountManagerReloader({ + reloadRuntimeAccountManager, + getReloadInFlight: () => null, + loadFromDisk: vi.fn(async () => "manager"), + setCachedAccountManager: vi.fn(), + setAccountManagerPromise: vi.fn(), + setReloadInFlight: vi.fn(), + }); + await expect( + reloader({ type: "oauth", access: "a", refresh: "r", expires: 1 }), + ).resolves.toBe("manager"); + expect(reloadRuntimeAccountManager).toHaveBeenCalledWith( + expect.objectContaining({ + authFallback: expect.objectContaining({ refresh: "r" }), + }), + ); + }); +}); From 87fe95792ccc6a4c5b77df60bc7af8b692bf0829 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 03:51:18 +0800 Subject: [PATCH 198/376] test: tighten config explain coverage --- test/codex-manager-cli.test.ts | 29 ++++++++++++++++++++- test/config-explain.test.ts | 46 +++++++++++++++++++++++++--------- 2 files changed, 62 insertions(+), 13 deletions(-) diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 5b83f1b5..5a0ee782 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -692,6 +692,13 @@ describe("codex manager cli commands", () => { }); it("prints config explain output in text mode", async () => { + getPluginConfigExplainReportMock.mockReturnValueOnce({ + configPath: "/mock/settings.json", + storageKind: "unified", + entries: [ + { key: "codexMode", value: true, defaultValue: true, source: "default", envNames: ["CODEX_MODE"] }, + ], + }); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); @@ -700,10 +707,30 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(logSpy).toHaveBeenCalledWith("Config storage: unified"); expect(logSpy).toHaveBeenCalledWith( - expect.stringContaining("codexMode = "), + expect.stringContaining("codexMode = true (default)"), ); }); + it("errors for unknown config explain args", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "config", "explain", "--bogus"]); + + expect(exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith("Unknown option: --bogus"); + }); + + it("errors for unknown config explain args", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "config", "explain", "--bogus"]); + + expect(exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith("Unknown option: --bogus"); + }); + it("errors for unknown config subcommands", async () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); diff --git a/test/config-explain.test.ts b/test/config-explain.test.ts index 5a5e5a7c..0287b7a2 100644 --- a/test/config-explain.test.ts +++ b/test/config-explain.test.ts @@ -25,23 +25,45 @@ describe("getPluginConfigExplainReport", () => { const { getPluginConfigExplainReport } = await import("../lib/config.js"); const report = getPluginConfigExplainReport(); const entry = report.entries.find((item) => item.key === "fastSessionStrategy"); + expect(entry).toBeDefined(); expect(entry?.source).not.toBe("env"); }); it("attributes alias-backed fallback policy values to stored config", async () => { const configPath = join(tmpdir(), `config-explain-${Date.now()}.json`); process.env.CODEX_MULTI_AUTH_CONFIG_PATH = configPath; - await fs.writeFile( - configPath, - JSON.stringify({ fallbackOnUnsupportedCodexModel: true }, null, 2), - "utf-8", - ); - const { getPluginConfigExplainReport } = await import("../lib/config.js"); - const report = getPluginConfigExplainReport(); - const policy = report.entries.find((item) => item.key === "unsupportedCodexPolicy"); - const fallback = report.entries.find((item) => item.key === "fallbackOnUnsupportedCodexModel"); - expect(policy?.source).toBe("file"); - expect(fallback?.source).toBe("file"); - await fs.unlink(configPath); + try { + await fs.writeFile( + configPath, + JSON.stringify({ fallbackOnUnsupportedCodexModel: true }, null, 2), + "utf-8", + ); + const { getPluginConfigExplainReport } = await import("../lib/config.js"); + const report = getPluginConfigExplainReport(); + const policy = report.entries.find((item) => item.key === "unsupportedCodexPolicy"); + const fallback = report.entries.find((item) => item.key === "fallbackOnUnsupportedCodexModel"); + expect(policy).toBeDefined(); + expect(fallback).toBeDefined(); + expect(policy?.source).toBe("file"); + expect(fallback?.source).toBe("file"); + } finally { + await fs.unlink(configPath).catch(() => {}); + } + }); + + it("reports default and env sources", async () => { + const mod = await import("../lib/config.js"); + let report = mod.getPluginConfigExplainReport(); + let entry = report.entries.find((item) => item.key === "codexMode"); + expect(entry).toBeDefined(); + expect(entry?.source).toBe("default"); + vi.resetModules(); + + process.env.CODEX_AUTH_FAST_SESSION_STRATEGY = "always"; + const modWithEnv = await import("../lib/config.js"); + report = modWithEnv.getPluginConfigExplainReport(); + entry = report.entries.find((item) => item.key === "fastSessionStrategy"); + expect(entry).toBeDefined(); + expect(entry?.source).toBe("env"); }); }); From ac94a5faf00913424f9d5706e8e7cc3344c87225 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 03:53:32 +0800 Subject: [PATCH 199/376] test: cover pack budget edge cases --- test/check-pack-budget.test.ts | 42 +++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/test/check-pack-budget.test.ts b/test/check-pack-budget.test.ts index e468c90e..a62c8224 100644 --- a/test/check-pack-budget.test.ts +++ b/test/check-pack-budget.test.ts @@ -27,11 +27,51 @@ describe("parsePackMetadata", () => { it("throws when npm pack returns no package metadata", () => { expect(() => parsePackMetadata("[]")).toThrow(/no package metadata/); }); + + it("throws when npm pack reports a non-positive package size", () => { + expect(() => + parsePackMetadata(JSON.stringify([{ size: 0, files: [] }])), + ).toThrow(/valid package size/); + }); }); describe("validatePackMetadata", () => { - it("rejects forbidden lib sources in the packed file list", () => { + + it("rejects oversized tarballs", () => { + expect(() => + validatePackMetadata({ + packageSize: 9 * 1024 * 1024, + paths: [ + "dist/index.js", + "assets/logo.svg", + "config/default.json", + "scripts/codex.js", + "vendor/codex-ai-plugin/index.js", + "vendor/codex-ai-sdk/index.js", + "README.md", + "LICENSE", + ], + }), + ).toThrow(/too large/); + }); + + it("rejects missing required package content", () => { expect(() => + validatePackMetadata({ + packageSize: 123, + paths: [ + "dist/index.js", + "assets/logo.svg", + "config/default.json", + "scripts/codex.js", + "vendor/codex-ai-plugin/index.js", + "README.md", + "LICENSE", + ], + }), + ).toThrow(/vendor\/codex-ai-sdk/); + }); + it("rejects forbidden lib sources in the packed file list", () => { expect(() => validatePackMetadata({ packageSize: 123, paths: [ From a16a7406fe4d3fd9a3851a090dedd5a34ccf3bdc Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 03:53:32 +0800 Subject: [PATCH 200/376] test: cover runtime toast edge cases --- test/runtime-toast.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/runtime-toast.test.ts b/test/runtime-toast.test.ts index eda1281b..a8e8558a 100644 --- a/test/runtime-toast.test.ts +++ b/test/runtime-toast.test.ts @@ -17,5 +17,14 @@ describe("showRuntimeToast", () => { it("silently ignores missing TUI clients", async () => { await expect(showRuntimeToast({}, "Saved")).resolves.toBeUndefined(); + await expect(showRuntimeToast({ tui: {} }, "Saved")).resolves.toBeUndefined(); + }); + + it("swallows TUI toast errors", async () => { + const showToast = vi.fn(async () => { + throw new Error("tui offline"); + }); + await expect(showRuntimeToast({ tui: { showToast } }, "Saved", "error")).resolves.toBeUndefined(); + expect(showToast).toHaveBeenCalledTimes(1); }); }); From e8ec74cbfa4b841f5f6171173325d6ff152d3d66 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 03:53:58 +0800 Subject: [PATCH 201/376] test: cover experimental settings schema --- test/experimental-settings-schema.test.ts | 41 +++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 test/experimental-settings-schema.test.ts diff --git a/test/experimental-settings-schema.test.ts b/test/experimental-settings-schema.test.ts new file mode 100644 index 00000000..4c549bc3 --- /dev/null +++ b/test/experimental-settings-schema.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { + getExperimentalSelectOptions, + mapExperimentalMenuHotkey, + mapExperimentalStatusHotkey, +} from "../lib/codex-manager/experimental-settings-schema.js"; + +describe("experimental settings schema", () => { + it("builds select options from ui runtime state", () => { + const options = getExperimentalSelectOptions( + { theme: { accent: "blue" } } as never, + "Help text", + () => undefined, + ); + expect(options).toMatchObject({ + message: expect.any(String), + subtitle: expect.any(String), + help: "Help text", + clearScreen: true, + selectedEmphasis: "minimal", + }); + expect(typeof options.onInput).toBe("function"); + }); + + it("maps experimental menu hotkeys", () => { + expect(mapExperimentalMenuHotkey("1")).toEqual({ type: "sync" }); + expect(mapExperimentalMenuHotkey("2")).toEqual({ type: "backup" }); + expect(mapExperimentalMenuHotkey("3")).toEqual({ type: "toggle-refresh-guardian" }); + expect(mapExperimentalMenuHotkey("[")).toEqual({ type: "decrease-refresh-interval" }); + expect(mapExperimentalMenuHotkey("]")).toEqual({ type: "increase-refresh-interval" }); + expect(mapExperimentalMenuHotkey("q")).toEqual({ type: "back" }); + expect(mapExperimentalMenuHotkey("s")).toEqual({ type: "save" }); + expect(mapExperimentalMenuHotkey("x")).toBeUndefined(); + }); + + it("maps experimental status hotkeys", () => { + expect(mapExperimentalStatusHotkey("q")).toEqual({ type: "back" }); + expect(mapExperimentalStatusHotkey("Q")).toEqual({ type: "back" }); + expect(mapExperimentalStatusHotkey("x")).toBeUndefined(); + }); +}); From 3674bd3843aea3fef6ed7dd3ddd8516c9c7ac887 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 03:56:17 +0800 Subject: [PATCH 202/376] test: cover backend settings helpers --- test/backend-settings-helpers.test.ts | 35 +++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 test/backend-settings-helpers.test.ts diff --git a/test/backend-settings-helpers.test.ts b/test/backend-settings-helpers.test.ts new file mode 100644 index 00000000..bf78d6f5 --- /dev/null +++ b/test/backend-settings-helpers.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { + backendSettingsEqual, + backendSettingsSnapshot, + buildBackendConfigPatch, + buildBackendSettingsPreview, + clampBackendNumberForTests, + cloneBackendPluginConfig, + formatBackendNumberValue, +} from "../lib/codex-manager/backend-settings-helpers.js"; + +describe("backend settings helpers", () => { + it("clones fallback chains defensively", () => { + const cloned = cloneBackendPluginConfig({ unsupportedCodexFallbackChain: { a: ["b"] } } as never); + expect(cloned.unsupportedCodexFallbackChain).toEqual({ a: ["b"] }); + expect(cloned.unsupportedCodexFallbackChain).not.toBe((({ unsupportedCodexFallbackChain: { a: ["b"] } } as never).unsupportedCodexFallbackChain)); + }); + + it("formats and clamps backend numeric values", () => { + expect(formatBackendNumberValue({ unit: "percent" } as never, 4.6)).toBe("5%"); + expect(formatBackendNumberValue({ unit: "count" } as never, 2.2)).toBe("2"); + expect(clampBackendNumberForTests("fetchTimeoutMs", 10)).toBeGreaterThanOrEqual(1000); + }); + + it("builds snapshots, equality, preview and patches", () => { + const left = { fetchTimeoutMs: 60000, streamStallTimeoutMs: 45000, liveAccountSync: true, sessionAffinity: true, preemptiveQuotaEnabled: true, preemptiveQuotaRemainingPercent5h: 5, preemptiveQuotaRemainingPercent7d: 5 } as never; + const right = { ...left } as never; + expect(backendSettingsSnapshot(left)).toEqual(backendSettingsSnapshot(right)); + expect(backendSettingsEqual(left, right)).toBe(true); + const preview = buildBackendSettingsPreview(left, { theme: {} } as never, "fetchTimeoutMs", { highlightPreviewToken: (text) => `[${text}]` }); + expect(preview.label).toContain("live sync"); + expect(preview.hint).toContain("timeouts"); + expect(buildBackendConfigPatch({ ...left, fetchTimeoutMs: 10 } as never)).toHaveProperty("fetchTimeoutMs"); + }); +}); From 08369a7c43b72d8223e3f985fae6189d781798e0 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 04:00:01 +0800 Subject: [PATCH 203/376] fix: add experimental interval hotkey aliases --- lib/codex-manager/experimental-settings-schema.ts | 4 ++-- test/experimental-settings-schema.test.ts | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/codex-manager/experimental-settings-schema.ts b/lib/codex-manager/experimental-settings-schema.ts index 3bc5b846..a9029181 100644 --- a/lib/codex-manager/experimental-settings-schema.ts +++ b/lib/codex-manager/experimental-settings-schema.ts @@ -34,8 +34,8 @@ export function mapExperimentalMenuHotkey( if (raw === "1") return { type: "sync" }; if (raw === "2") return { type: "backup" }; if (raw === "3") return { type: "toggle-refresh-guardian" }; - if (raw === "[" || raw === "-") return { type: "decrease-refresh-interval" }; - if (raw === "]" || raw === "+") return { type: "increase-refresh-interval" }; + if (raw === "[" || raw === "-" || raw.toLowerCase() === "a") return { type: "decrease-refresh-interval" }; + if (raw === "]" || raw === "+" || raw === "=" || raw.toLowerCase() === "d") return { type: "increase-refresh-interval" }; const lower = raw.toLowerCase(); if (lower === "q") return { type: "back" }; if (lower === "s") return { type: "save" }; diff --git a/test/experimental-settings-schema.test.ts b/test/experimental-settings-schema.test.ts index 4c549bc3..d0393842 100644 --- a/test/experimental-settings-schema.test.ts +++ b/test/experimental-settings-schema.test.ts @@ -27,7 +27,10 @@ describe("experimental settings schema", () => { expect(mapExperimentalMenuHotkey("2")).toEqual({ type: "backup" }); expect(mapExperimentalMenuHotkey("3")).toEqual({ type: "toggle-refresh-guardian" }); expect(mapExperimentalMenuHotkey("[")).toEqual({ type: "decrease-refresh-interval" }); + expect(mapExperimentalMenuHotkey("a")).toEqual({ type: "decrease-refresh-interval" }); expect(mapExperimentalMenuHotkey("]")).toEqual({ type: "increase-refresh-interval" }); + expect(mapExperimentalMenuHotkey("=")).toEqual({ type: "increase-refresh-interval" }); + expect(mapExperimentalMenuHotkey("d")).toEqual({ type: "increase-refresh-interval" }); expect(mapExperimentalMenuHotkey("q")).toEqual({ type: "back" }); expect(mapExperimentalMenuHotkey("s")).toEqual({ type: "save" }); expect(mapExperimentalMenuHotkey("x")).toBeUndefined(); From 564969d12147070cb9e486e1a6690c583fe4bcf5 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 04:00:01 +0800 Subject: [PATCH 204/376] fix: clarify pack budget failures --- scripts/check-pack-budget-lib.js | 22 +++++++++++++++++----- test/check-pack-budget.test.ts | 20 ++++++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/scripts/check-pack-budget-lib.js b/scripts/check-pack-budget-lib.js index 7b2bb600..59b76677 100644 --- a/scripts/check-pack-budget-lib.js +++ b/scripts/check-pack-budget-lib.js @@ -87,11 +87,23 @@ export async function runPackBudgetCheck(deps = {}) { const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; const runExec = deps.execAsync ?? execAsync; const log = deps.log ?? console.log; - const { stdout } = await runExec(`${npmCommand} pack --dry-run --json`, { - windowsHide: true, - maxBuffer: 10 * 1024 * 1024, - }); - const summary = validatePackMetadata(parsePackMetadata(stdout)); + let stdout = ""; + try { + ({ stdout } = await runExec(`${npmCommand} pack --dry-run --json`, { + windowsHide: true, + maxBuffer: 10 * 1024 * 1024, + })); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`npm pack --dry-run --json failed via ${npmCommand}: ${message}`); + } + let summary; + try { + summary = validatePackMetadata(parsePackMetadata(stdout)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to validate npm pack output: ${message}`); + } log(summary); return summary; } diff --git a/test/check-pack-budget.test.ts b/test/check-pack-budget.test.ts index a62c8224..3d5e0987 100644 --- a/test/check-pack-budget.test.ts +++ b/test/check-pack-budget.test.ts @@ -33,6 +33,26 @@ describe("parsePackMetadata", () => { parsePackMetadata(JSON.stringify([{ size: 0, files: [] }])), ).toThrow(/valid package size/); }); + it("wraps npm pack execution errors with command context", async () => { + await expect( + runPackBudgetCheck({ + execAsync: vi.fn(async () => { + throw new Error("spawn failed"); + }), + log: vi.fn(), + }), + ).rejects.toThrow(/npm pack --dry-run --json failed/); + }); + + it("wraps malformed pack output errors with validation context", async () => { + await expect( + runPackBudgetCheck({ + execAsync: vi.fn(async () => ({ stdout: "not-json" })), + log: vi.fn(), + }), + ).rejects.toThrow(/Failed to validate npm pack output/); + }); + }); describe("validatePackMetadata", () => { From 518b730c9db44fe0b717a2176a775918bb0323e7 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 04:04:33 +0800 Subject: [PATCH 205/376] fix: preserve experimental hotkey behavior --- lib/codex-manager/experimental-settings-schema.ts | 4 ++-- test/experimental-settings-schema.test.ts | 3 --- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/lib/codex-manager/experimental-settings-schema.ts b/lib/codex-manager/experimental-settings-schema.ts index a9029181..3bc5b846 100644 --- a/lib/codex-manager/experimental-settings-schema.ts +++ b/lib/codex-manager/experimental-settings-schema.ts @@ -34,8 +34,8 @@ export function mapExperimentalMenuHotkey( if (raw === "1") return { type: "sync" }; if (raw === "2") return { type: "backup" }; if (raw === "3") return { type: "toggle-refresh-guardian" }; - if (raw === "[" || raw === "-" || raw.toLowerCase() === "a") return { type: "decrease-refresh-interval" }; - if (raw === "]" || raw === "+" || raw === "=" || raw.toLowerCase() === "d") return { type: "increase-refresh-interval" }; + if (raw === "[" || raw === "-") return { type: "decrease-refresh-interval" }; + if (raw === "]" || raw === "+") return { type: "increase-refresh-interval" }; const lower = raw.toLowerCase(); if (lower === "q") return { type: "back" }; if (lower === "s") return { type: "save" }; diff --git a/test/experimental-settings-schema.test.ts b/test/experimental-settings-schema.test.ts index d0393842..4c549bc3 100644 --- a/test/experimental-settings-schema.test.ts +++ b/test/experimental-settings-schema.test.ts @@ -27,10 +27,7 @@ describe("experimental settings schema", () => { expect(mapExperimentalMenuHotkey("2")).toEqual({ type: "backup" }); expect(mapExperimentalMenuHotkey("3")).toEqual({ type: "toggle-refresh-guardian" }); expect(mapExperimentalMenuHotkey("[")).toEqual({ type: "decrease-refresh-interval" }); - expect(mapExperimentalMenuHotkey("a")).toEqual({ type: "decrease-refresh-interval" }); expect(mapExperimentalMenuHotkey("]")).toEqual({ type: "increase-refresh-interval" }); - expect(mapExperimentalMenuHotkey("=")).toEqual({ type: "increase-refresh-interval" }); - expect(mapExperimentalMenuHotkey("d")).toEqual({ type: "increase-refresh-interval" }); expect(mapExperimentalMenuHotkey("q")).toEqual({ type: "back" }); expect(mapExperimentalMenuHotkey("s")).toEqual({ type: "save" }); expect(mapExperimentalMenuHotkey("x")).toBeUndefined(); From 639d24ad06043f9a686df2e3a5bbddc36dfcc2cf Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 04:34:54 +0800 Subject: [PATCH 206/376] fix: align runtime helper logging output --- lib/runtime/account-check.ts | 4 +++- lib/runtime/auth-facade.ts | 2 +- lib/runtime/verify-flagged.ts | 4 +++- test/runtime-auth-facade.test.ts | 4 ++-- test/runtime-verify-flagged.test.ts | 4 ++-- 5 files changed, 11 insertions(+), 7 deletions(-) diff --git a/lib/runtime/account-check.ts b/lib/runtime/account-check.ts index 2b2b2276..6d1a36d0 100644 --- a/lib/runtime/account-check.ts +++ b/lib/runtime/account-check.ts @@ -1,3 +1,4 @@ +import { maskEmail } from "../logger.js"; import type { ModelFamily } from "../prompts/codex.js"; import type { AccountStorageV3, FlaggedAccountMetadataV1 } from "../storage.js"; import type { AccountIdSource, TokenResult } from "../types.js"; @@ -95,7 +96,8 @@ export async function runRuntimeAccountCheck( for (let i = 0; i < total; i += 1) { const account = workingStorage.accounts[i]; if (!account) continue; - const label = account.email ?? account.accountLabel ?? `Account ${i + 1}`; + const maskedEmail = account.email ? maskEmail(account.email) : undefined; + const label = account.accountLabel ?? maskedEmail ?? `Account ${i + 1}`; if (account.enabled === false) { state.disabled += 1; deps.showLine(`[${i + 1}/${total}] ${label}: DISABLED`); diff --git a/lib/runtime/auth-facade.ts b/lib/runtime/auth-facade.ts index 844a2fa6..1b9a60fd 100644 --- a/lib/runtime/auth-facade.ts +++ b/lib/runtime/auth-facade.ts @@ -22,7 +22,7 @@ export async function runRuntimeOAuthFlow( return deps.runOAuthBrowserFlow({ forceNewLogin, manualModeLabel: deps.manualModeLabel, - logInfo: (message) => deps.logInfo(`[${deps.pluginName}] ${message}`), + logInfo: deps.logInfo, logDebug: (message) => deps.logDebug(`[${deps.pluginName}] ${message}`), logWarn: (message) => deps.logWarn(`[${deps.pluginName}] ${message}`), }); diff --git a/lib/runtime/verify-flagged.ts b/lib/runtime/verify-flagged.ts index 55c528ae..6d90a483 100644 --- a/lib/runtime/verify-flagged.ts +++ b/lib/runtime/verify-flagged.ts @@ -1,3 +1,4 @@ +import { maskEmail } from "../logger.js"; import type { FlaggedAccountMetadataV1 } from "../storage.js"; import type { TokenSuccessWithAccount } from "./account-selection.js"; import { createFlaggedVerificationState } from "./flagged-verify-types.js"; @@ -63,7 +64,8 @@ export async function verifyRuntimeFlaggedAccounts(deps: { for (let i = 0; i < flaggedStorage.accounts.length; i += 1) { const flagged = flaggedStorage.accounts[i]; if (!flagged) continue; - const label = flagged.email ?? flagged.accountLabel ?? `Flagged ${i + 1}`; + const maskedEmail = flagged.email ? maskEmail(flagged.email) : undefined; + const label = flagged.accountLabel ?? maskedEmail ?? `Flagged ${i + 1}`; try { const cached = await deps.lookupCodexCliTokensByEmail(flagged.email); const now = deps.now?.() ?? Date.now(); diff --git a/test/runtime-auth-facade.test.ts b/test/runtime-auth-facade.test.ts index cce8b32c..0410d51c 100644 --- a/test/runtime-auth-facade.test.ts +++ b/test/runtime-auth-facade.test.ts @@ -6,7 +6,7 @@ import { } from "../lib/runtime/auth-facade.js"; describe("runRuntimeOAuthFlow", () => { - it("prefixes info, debug and warn logs with the plugin name", async () => { + it("passes through info logs and prefixes debug/warn logs with the plugin name", async () => { const logInfo = vi.fn(); const logDebug = vi.fn(); const logWarn = vi.fn(); @@ -23,7 +23,7 @@ describe("runRuntimeOAuthFlow", () => { logWarn, pluginName: "codex-multi-auth", }); - expect(logInfo).toHaveBeenCalledWith("[codex-multi-auth] info message"); + expect(logInfo).toHaveBeenCalledWith("info message"); expect(logDebug).toHaveBeenCalledWith("[codex-multi-auth] debug message"); expect(logWarn).toHaveBeenCalledWith("[codex-multi-auth] warn message"); }); diff --git a/test/runtime-verify-flagged.test.ts b/test/runtime-verify-flagged.test.ts index c79c7eeb..9bb464a9 100644 --- a/test/runtime-verify-flagged.test.ts +++ b/test/runtime-verify-flagged.test.ts @@ -31,7 +31,7 @@ describe("verifyRuntimeFlaggedAccounts", () => { version: 1, accounts: [expect.objectContaining({ refreshToken: "flagged-refresh" })], }); - expect(showLine).toHaveBeenCalledWith(expect.stringContaining("RESTORED (Codex CLI cache)")); + expect(showLine).toHaveBeenCalledWith(expect.stringContaining("ca***@***.com: RESTORED (Codex CLI cache)")); }); it("logs verification failures through logError and keeps the account flagged", async () => { @@ -55,7 +55,7 @@ describe("verifyRuntimeFlaggedAccounts", () => { showLine: vi.fn(), }); expect(logError).toHaveBeenCalledWith( - expect.stringContaining("Failed to verify flagged account broken@example.com: cache unavailable"), + expect.stringContaining("Failed to verify flagged account br***@***.com: cache unavailable"), ); expect(saveFlaggedAccounts).toHaveBeenCalledWith({ version: 1, From bb30ca7fc99154f7ad8d5a37112a69a86e9d6ba4 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 04:34:59 +0800 Subject: [PATCH 207/376] fix: restore backend settings schema parity --- lib/codex-manager/backend-settings-schema.ts | 30 +++++++------ test/codex-manager-cli.test.ts | 44 ++++++++++++++++++++ 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/lib/codex-manager/backend-settings-schema.ts b/lib/codex-manager/backend-settings-schema.ts index 1995b913..1c4ef58f 100644 --- a/lib/codex-manager/backend-settings-schema.ts +++ b/lib/codex-manager/backend-settings-schema.ts @@ -143,7 +143,7 @@ export const BACKEND_NUMBER_OPTIONS: BackendNumberSettingOption[] = [ label: "Live Sync Debounce", description: "Delay before reacting to file changes.", min: 50, - max: 60_000, + max: 10_000, step: 50, unit: "ms", }, @@ -152,7 +152,7 @@ export const BACKEND_NUMBER_OPTIONS: BackendNumberSettingOption[] = [ label: "Live Sync Poll Interval", description: "Polling fallback interval for external file changes.", min: 500, - max: 120_000, + max: 60_000, step: 500, unit: "ms", }, @@ -170,8 +170,8 @@ export const BACKEND_NUMBER_OPTIONS: BackendNumberSettingOption[] = [ label: "Session Affinity Max Entries", description: "Upper bound for tracked affinity sessions.", min: 8, - max: 10_000, - step: 8, + max: 4_096, + step: 32, unit: "count", }, { @@ -179,7 +179,7 @@ export const BACKEND_NUMBER_OPTIONS: BackendNumberSettingOption[] = [ label: "Refresh Guard Interval", description: "How often the guard scans for refresh work.", min: 5_000, - max: 3_600_000, + max: 600_000, step: 5_000, unit: "ms", }, @@ -188,7 +188,7 @@ export const BACKEND_NUMBER_OPTIONS: BackendNumberSettingOption[] = [ label: "Refresh Guard Buffer", description: "How early tokens should refresh before expiry.", min: 30_000, - max: 7_200_000, + max: 600_000, step: 30_000, unit: "ms", }, @@ -197,7 +197,7 @@ export const BACKEND_NUMBER_OPTIONS: BackendNumberSettingOption[] = [ label: "Parallel Probe Concurrency", description: "Maximum simultaneous account probes.", min: 1, - max: 32, + max: 5, step: 1, unit: "count", }, @@ -215,8 +215,8 @@ export const BACKEND_NUMBER_OPTIONS: BackendNumberSettingOption[] = [ label: "Network Error Cooldown", description: "Cooldown applied after network failures.", min: 0, - max: 300_000, - step: 1_000, + max: 120_000, + step: 500, unit: "ms", }, { @@ -224,8 +224,8 @@ export const BACKEND_NUMBER_OPTIONS: BackendNumberSettingOption[] = [ label: "Server Error Cooldown", description: "Cooldown applied after upstream server failures.", min: 0, - max: 300_000, - step: 1_000, + max: 120_000, + step: 500, unit: "ms", }, { @@ -318,8 +318,12 @@ export const BACKEND_CATEGORY_OPTIONS: BackendCategoryOption[] = [ key: "refresh-recovery", label: "Refresh & Recovery", description: "Token refresh and recovery safety.", - toggleKeys: ["storageBackupEnabled"], - numberKeys: ["proactiveRefreshBufferMs", "tokenRefreshSkewMs"], + toggleKeys: ["proactiveRefreshGuardian", "storageBackupEnabled"], + numberKeys: [ + "proactiveRefreshIntervalMs", + "proactiveRefreshBufferMs", + "tokenRefreshSkewMs", + ], }, { key: "performance-timeouts", diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 6a6a824d..54c4b24a 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -576,6 +576,50 @@ describe("codex manager cli commands", () => { vi.restoreAllMocks(); }); + it("keeps backend settings schema maps, categories, and defaults aligned", async () => { + const { + BACKEND_CATEGORY_OPTIONS, + BACKEND_DEFAULTS, + BACKEND_NUMBER_OPTIONS, + BACKEND_NUMBER_OPTION_BY_KEY, + BACKEND_TOGGLE_OPTIONS, + BACKEND_TOGGLE_OPTION_BY_KEY, + } = await import("../lib/codex-manager/backend-settings-schema.js"); + + const toggleKeys = BACKEND_TOGGLE_OPTIONS.map((option) => option.key); + const numberKeys = BACKEND_NUMBER_OPTIONS.map((option) => option.key); + const categorizedToggleKeys = new Set( + BACKEND_CATEGORY_OPTIONS.flatMap((category) => category.toggleKeys), + ); + const categorizedNumberKeys = new Set( + BACKEND_CATEGORY_OPTIONS.flatMap((category) => category.numberKeys), + ); + + expect(toggleKeys.every((key) => BACKEND_TOGGLE_OPTION_BY_KEY.has(key))).toBe( + true, + ); + expect(numberKeys.every((key) => BACKEND_NUMBER_OPTION_BY_KEY.has(key))).toBe( + true, + ); + expect( + BACKEND_CATEGORY_OPTIONS.every((category) => + category.toggleKeys.every((key) => BACKEND_TOGGLE_OPTION_BY_KEY.has(key)), + ), + ).toBe(true); + expect( + BACKEND_CATEGORY_OPTIONS.every((category) => + category.numberKeys.every((key) => BACKEND_NUMBER_OPTION_BY_KEY.has(key)), + ), + ).toBe(true); + expect(toggleKeys.every((key) => categorizedToggleKeys.has(key))).toBe(true); + expect(numberKeys.every((key) => categorizedNumberKeys.has(key))).toBe(true); + expect( + [...toggleKeys, ...numberKeys].every((key) => + Object.prototype.hasOwnProperty.call(BACKEND_DEFAULTS, key), + ), + ).toBe(true); + }); + it("formats backup saved-at timestamps with the runtime locale options", async () => { const localeSpy = vi .spyOn(Date.prototype, "toLocaleString") From 20f29bfe2697f094e9205b13f0f15e672bb332de Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 04:37:13 +0800 Subject: [PATCH 208/376] fix: normalize restore snapshot selection --- lib/storage/restore-assessment.ts | 29 ++++++++++--------- test/restore-assessment.test.ts | 46 +++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 13 deletions(-) create mode 100644 test/restore-assessment.test.ts diff --git a/lib/storage/restore-assessment.ts b/lib/storage/restore-assessment.ts index fe456bff..eb6df5b2 100644 --- a/lib/storage/restore-assessment.ts +++ b/lib/storage/restore-assessment.ts @@ -3,6 +3,19 @@ import type { BackupSnapshotMetadata } from "./backup-metadata.js"; type BackupSnapshotKind = BackupSnapshotMetadata["kind"]; +function normalizeSnapshotPath(path: string): string { + return path.replaceAll("\\", "/"); +} + +function resolveLatestSnapshot(backupMetadata: BackupMetadata): BackupSnapshotMetadata | undefined { + const latestValidPath = backupMetadata.accounts.latestValidPath; + if (!latestValidPath) return undefined; + const normalizedLatest = normalizeSnapshotPath(latestValidPath); + return backupMetadata.accounts.snapshots.find( + (snapshot) => normalizeSnapshotPath(snapshot.path) === normalizedLatest, + ); +} + export async function collectBackupMetadata(deps: { storagePath: string; flaggedPath: string; @@ -99,12 +112,7 @@ export function buildRestoreAssessment(deps: { storagePath: deps.storagePath, restoreEligible: true, restoreReason: "missing-storage", - latestSnapshot: deps.backupMetadata.accounts.latestValidPath - ? deps.backupMetadata.accounts.snapshots.find( - (snapshot) => - snapshot.path === deps.backupMetadata.accounts.latestValidPath, - ) - : undefined, + latestSnapshot: resolveLatestSnapshot(deps.backupMetadata), backupMetadata: deps.backupMetadata, }; } @@ -113,19 +121,14 @@ export function buildRestoreAssessment(deps: { storagePath: deps.storagePath, restoreEligible: true, restoreReason: "empty-storage", - latestSnapshot: primarySnapshot, + latestSnapshot: resolveLatestSnapshot(deps.backupMetadata) ?? primarySnapshot, backupMetadata: deps.backupMetadata, }; } return { storagePath: deps.storagePath, restoreEligible: false, - latestSnapshot: deps.backupMetadata.accounts.latestValidPath - ? deps.backupMetadata.accounts.snapshots.find( - (snapshot) => - snapshot.path === deps.backupMetadata.accounts.latestValidPath, - ) - : undefined, + latestSnapshot: resolveLatestSnapshot(deps.backupMetadata), backupMetadata: deps.backupMetadata, }; } diff --git a/test/restore-assessment.test.ts b/test/restore-assessment.test.ts new file mode 100644 index 00000000..e36e4884 --- /dev/null +++ b/test/restore-assessment.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { buildRestoreAssessment } from "../lib/storage/restore-assessment.js"; + +describe("buildRestoreAssessment", () => { + it("prefers the latest valid backup over an empty primary", () => { + const assessment = buildRestoreAssessment({ + storagePath: "C:/repo/accounts.json", + resetMarkerExists: false, + backupMetadata: { + accounts: { + path: "C:/repo/accounts.json", + latestValidPath: "C:/repo/accounts.json.bak", + snapshots: [ + { kind: "accounts-primary", path: "C:/repo/accounts.json", exists: true, valid: true, accountCount: 0 }, + { kind: "accounts-backup", path: "C:/repo/accounts.json.bak", exists: true, valid: true, accountCount: 2 }, + ], + }, + flaggedAccounts: { path: "C:/repo/flagged.json", latestValidPath: undefined, snapshots: [] }, + }, + }); + expect(assessment.restoreEligible).toBe(true); + expect(assessment.restoreReason).toBe("empty-storage"); + expect(assessment.latestSnapshot?.path).toBe("C:/repo/accounts.json.bak"); + }); + + it("matches latest valid snapshot paths across path separators", () => { + const assessment = buildRestoreAssessment({ + storagePath: "C:/repo/accounts.json", + resetMarkerExists: false, + backupMetadata: { + accounts: { + path: "C:/repo/accounts.json", + latestValidPath: "C:\\repo\\accounts.json.bak", + snapshots: [ + { kind: "accounts-primary", path: "C:/repo/accounts.json", exists: false, valid: false }, + { kind: "accounts-backup", path: "C:/repo/accounts.json.bak", exists: true, valid: true, accountCount: 1 }, + ], + }, + flaggedAccounts: { path: "C:/repo/flagged.json", latestValidPath: undefined, snapshots: [] }, + }, + }); + expect(assessment.restoreEligible).toBe(true); + expect(assessment.restoreReason).toBe("missing-storage"); + expect(assessment.latestSnapshot?.path).toBe("C:/repo/accounts.json.bak"); + }); +}); From bdcb3494d5c186c1784461cf1fa98a1f5d263133 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 04:38:35 +0800 Subject: [PATCH 209/376] refactor: share runtime account check deps --- index.ts | 87 ++++++++++++++++++++------------------------------------ 1 file changed, 31 insertions(+), 56 deletions(-) diff --git a/index.ts b/index.ts index cd6bff47..80c10a2b 100644 --- a/index.ts +++ b/index.ts @@ -2413,66 +2413,41 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }; } + const accountCheckDeps = { + hydrateEmails, + loadAccounts, + createEmptyStorage: () => ({ + version: 3 as const, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }), + loadFlaggedAccounts, + createAccountCheckWorkingState, + lookupCodexCliTokensByEmail, + extractAccountId, + shouldUpdateAccountIdFromToken, + sanitizeEmail, + extractAccountEmail, + queuedRefresh, + isRuntimeFlaggableFailure, + fetchCodexQuotaSnapshot, + resolveRequestAccountId, + formatCodexQuotaLine, + clampRuntimeActiveIndices, + MODEL_FAMILIES, + saveAccounts, + invalidateAccountManagerCache, + saveFlaggedAccounts, + showLine: (message: string) => console.log(message), + }; + if (menuResult.mode === "check") { - await runRuntimeAccountCheck(false, { - hydrateEmails, - loadAccounts, - createEmptyStorage: () => ({ - version: 3 as const, - accounts: [], - activeIndex: 0, - activeIndexByFamily: {}, - }), - loadFlaggedAccounts, - createAccountCheckWorkingState, - lookupCodexCliTokensByEmail, - extractAccountId, - shouldUpdateAccountIdFromToken, - sanitizeEmail, - extractAccountEmail, - queuedRefresh, - isRuntimeFlaggableFailure, - fetchCodexQuotaSnapshot, - resolveRequestAccountId, - formatCodexQuotaLine, - clampRuntimeActiveIndices, - MODEL_FAMILIES, - saveAccounts, - invalidateAccountManagerCache, - saveFlaggedAccounts, - showLine: (message) => console.log(message), - }); + await runRuntimeAccountCheck(false, accountCheckDeps); continue; } if (menuResult.mode === "deep-check") { - await runRuntimeAccountCheck(true, { - hydrateEmails, - loadAccounts, - createEmptyStorage: () => ({ - version: 3 as const, - accounts: [], - activeIndex: 0, - activeIndexByFamily: {}, - }), - loadFlaggedAccounts, - createAccountCheckWorkingState, - lookupCodexCliTokensByEmail, - extractAccountId, - shouldUpdateAccountIdFromToken, - sanitizeEmail, - extractAccountEmail, - queuedRefresh, - isRuntimeFlaggableFailure, - fetchCodexQuotaSnapshot, - resolveRequestAccountId, - formatCodexQuotaLine, - clampRuntimeActiveIndices, - MODEL_FAMILIES, - saveAccounts, - invalidateAccountManagerCache, - saveFlaggedAccounts, - showLine: (message) => console.log(message), - }); + await runRuntimeAccountCheck(true, accountCheckDeps); continue; } if (menuResult.mode === "verify-flagged") { From 16acf573772ca496f386a063fbf63e56706bcf9d Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 04:39:27 +0800 Subject: [PATCH 210/376] refactor: split runtime settings and storage helpers --- index.ts | 7259 +++++++++-------- lib/codex-manager/backend-category-helpers.ts | 60 + lib/codex-manager/backend-category-prompt.ts | 308 + .../backend-settings-controller.ts | 33 + lib/codex-manager/backend-settings-entry.ts | 49 + .../dashboard-settings-controller.ts | 40 + .../experimental-settings-prompt.ts | 343 + lib/codex-manager/experimental-sync-target.ts | 63 + lib/codex-manager/settings-hub-menu.ts | 62 + lib/codex-manager/settings-hub-prompt.ts | 53 + lib/codex-manager/settings-hub.ts | 1182 +-- lib/codex-manager/settings-preview.ts | 192 + .../unified-settings-controller.ts | 123 + lib/request/failover-config.ts | 14 + lib/request/request-init.ts | 72 + lib/request/response-metadata.ts | 65 + lib/request/wait-utils.ts | 70 + lib/runtime/account-check-helpers.ts | 42 + lib/runtime/account-pool.ts | 205 + lib/runtime/account-selection.ts | 85 + lib/runtime/account-status.ts | 52 + lib/runtime/browser-oauth-flow.ts | 69 + lib/runtime/manual-oauth-flow.ts | 77 + lib/runtime/runtime-services.ts | 161 + lib/runtime/storage-scope.ts | 34 + lib/runtime/ui-runtime.ts | 35 + lib/storage.ts | 1048 +-- lib/storage/account-clear.ts | 38 + lib/storage/account-persistence.ts | 16 + lib/storage/backup-metadata-builder.ts | 113 + lib/storage/backup-paths.ts | 38 + lib/storage/backup-restore.ts | 71 + lib/storage/fixture-guards.ts | 38 + lib/storage/flagged-storage-io.ts | 165 + lib/storage/flagged-storage.ts | 131 + lib/storage/import-export.ts | 109 + lib/storage/metadata-section.ts | 29 + lib/storage/named-backups.ts | 85 + lib/storage/project-migration.ts | 54 + lib/storage/restore-assessment.ts | 55 + lib/storage/storage-parser.ts | 40 + lib/storage/transactions.ts | 114 + test/account-check-helpers.test.ts | 65 + test/account-clear.test.ts | 16 + test/account-persistence.test.ts | 27 + test/account-selection.test.ts | 75 + test/account-status.test.ts | 64 + test/backend-category-helpers.test.ts | 64 + test/backend-category-prompt.test.ts | 57 + test/backend-settings-controller.test.ts | 76 + test/backend-settings-entry.test.ts | 23 + test/backup-metadata-builder.test.ts | 51 + test/backup-paths.test.ts | 35 + test/backup-restore.test.ts | 59 + test/browser-oauth-flow.test.ts | 102 + test/dashboard-settings-controller.test.ts | 96 + test/experimental-settings-prompt.test.ts | 109 + test/experimental-sync-target.test.ts | 102 + test/failover-config.test.ts | 21 + test/fixture-guards.test.ts | 55 + test/flagged-storage-io.test.ts | 56 + test/flagged-storage.test.ts | 31 + test/import-export.test.ts | 48 + test/manual-oauth-flow.test.ts | 87 + test/metadata-section.test.ts | 32 + test/project-migration.test.ts | 77 + test/request-init.test.ts | 41 + test/response-metadata.test.ts | 72 + test/restore-assessment.test.ts | 114 + test/runtime-services.test.ts | 105 + test/settings-hub-menu.test.ts | 33 + test/settings-hub-prompt.test.ts | 34 + test/storage-named-backups.test.ts | 102 + test/storage-parser.test.ts | 41 + test/storage-scope.test.ts | 46 + test/transactions.test.ts | 83 + test/unified-settings-controller.test.ts | 128 + test/wait-utils.test.ts | 46 + 78 files changed, 9816 insertions(+), 5349 deletions(-) create mode 100644 lib/codex-manager/backend-category-helpers.ts create mode 100644 lib/codex-manager/backend-category-prompt.ts create mode 100644 lib/codex-manager/backend-settings-controller.ts create mode 100644 lib/codex-manager/backend-settings-entry.ts create mode 100644 lib/codex-manager/dashboard-settings-controller.ts create mode 100644 lib/codex-manager/experimental-settings-prompt.ts create mode 100644 lib/codex-manager/experimental-sync-target.ts create mode 100644 lib/codex-manager/settings-hub-menu.ts create mode 100644 lib/codex-manager/settings-hub-prompt.ts create mode 100644 lib/codex-manager/settings-preview.ts create mode 100644 lib/codex-manager/unified-settings-controller.ts create mode 100644 lib/request/failover-config.ts create mode 100644 lib/request/request-init.ts create mode 100644 lib/request/response-metadata.ts create mode 100644 lib/request/wait-utils.ts create mode 100644 lib/runtime/account-check-helpers.ts create mode 100644 lib/runtime/account-pool.ts create mode 100644 lib/runtime/account-selection.ts create mode 100644 lib/runtime/account-status.ts create mode 100644 lib/runtime/browser-oauth-flow.ts create mode 100644 lib/runtime/manual-oauth-flow.ts create mode 100644 lib/runtime/runtime-services.ts create mode 100644 lib/runtime/storage-scope.ts create mode 100644 lib/runtime/ui-runtime.ts create mode 100644 lib/storage/account-clear.ts create mode 100644 lib/storage/account-persistence.ts create mode 100644 lib/storage/backup-metadata-builder.ts create mode 100644 lib/storage/backup-paths.ts create mode 100644 lib/storage/backup-restore.ts create mode 100644 lib/storage/fixture-guards.ts create mode 100644 lib/storage/flagged-storage-io.ts create mode 100644 lib/storage/flagged-storage.ts create mode 100644 lib/storage/import-export.ts create mode 100644 lib/storage/metadata-section.ts create mode 100644 lib/storage/named-backups.ts create mode 100644 lib/storage/project-migration.ts create mode 100644 lib/storage/restore-assessment.ts create mode 100644 lib/storage/storage-parser.ts create mode 100644 lib/storage/transactions.ts create mode 100644 test/account-check-helpers.test.ts create mode 100644 test/account-clear.test.ts create mode 100644 test/account-persistence.test.ts create mode 100644 test/account-selection.test.ts create mode 100644 test/account-status.test.ts create mode 100644 test/backend-category-helpers.test.ts create mode 100644 test/backend-category-prompt.test.ts create mode 100644 test/backend-settings-controller.test.ts create mode 100644 test/backend-settings-entry.test.ts create mode 100644 test/backup-metadata-builder.test.ts create mode 100644 test/backup-paths.test.ts create mode 100644 test/backup-restore.test.ts create mode 100644 test/browser-oauth-flow.test.ts create mode 100644 test/dashboard-settings-controller.test.ts create mode 100644 test/experimental-settings-prompt.test.ts create mode 100644 test/experimental-sync-target.test.ts create mode 100644 test/failover-config.test.ts create mode 100644 test/fixture-guards.test.ts create mode 100644 test/flagged-storage-io.test.ts create mode 100644 test/flagged-storage.test.ts create mode 100644 test/import-export.test.ts create mode 100644 test/manual-oauth-flow.test.ts create mode 100644 test/metadata-section.test.ts create mode 100644 test/project-migration.test.ts create mode 100644 test/request-init.test.ts create mode 100644 test/response-metadata.test.ts create mode 100644 test/restore-assessment.test.ts create mode 100644 test/runtime-services.test.ts create mode 100644 test/settings-hub-menu.test.ts create mode 100644 test/settings-hub-prompt.test.ts create mode 100644 test/storage-named-backups.test.ts create mode 100644 test/storage-parser.test.ts create mode 100644 test/storage-scope.test.ts create mode 100644 test/transactions.test.ts create mode 100644 test/unified-settings-controller.test.ts create mode 100644 test/wait-utils.test.ts diff --git a/index.ts b/index.ts index 368daaf3..325e0727 100644 --- a/index.ts +++ b/index.ts @@ -23,171 +23,228 @@ */ -import { tool } from "@codex-ai/plugin/tool"; import type { Plugin, PluginInput } from "@codex-ai/plugin"; +import { tool } from "@codex-ai/plugin/tool"; import type { Auth } from "@codex-ai/sdk"; import { - createAuthorizationFlow, - exchangeAuthorizationCode, - parseAuthorizationInput, - redactOAuthUrlForLog, - REDIRECT_URI, + AccountManager, + extractAccountEmail, + extractAccountId, + formatAccountLabel, + formatCooldown, + formatWaitTime, + getAccountIdCandidates, + isCodexCliSyncEnabled, + lookupCodexCliTokensByEmail, + parseRateLimitReason, + resolveRequestAccountId, + resolveRuntimeRequestIdentity, + sanitizeEmail, + selectBestAccountCandidate, + shouldUpdateAccountIdFromToken, + type Workspace, +} from "./lib/accounts.js"; +import { + createAuthorizationFlow, + exchangeAuthorizationCode, + parseAuthorizationInput, + REDIRECT_URI, + redactOAuthUrlForLog, } from "./lib/auth/auth.js"; -import { queuedRefresh } from "./lib/refresh-queue.js"; -import { isBrowserLaunchSuppressed, openBrowserUrl } from "./lib/auth/browser.js"; +import { + isBrowserLaunchSuppressed, + openBrowserUrl, +} from "./lib/auth/browser.js"; import { startLocalOAuthServer } from "./lib/auth/server.js"; +import { checkAndNotify } from "./lib/auto-update-checker.js"; +import { CapabilityPolicyStore } from "./lib/capability-policy.js"; import { promptAddAnotherAccount, promptLoginMode } from "./lib/cli.js"; import { - getCodexMode, - getFastSession, - getFastSessionStrategy, - getFastSessionMaxInputItems, - getRateLimitToastDebounceMs, - getRetryAllAccountsMaxRetries, - getRetryAllAccountsMaxWaitMs, - getRetryAllAccountsRateLimited, - getFallbackToGpt52OnUnsupportedGpt53, - getUnsupportedCodexPolicy, - getUnsupportedCodexFallbackChain, - getTokenRefreshSkewMs, - getSessionRecovery, getAutoResume, - getToastDurationMs, - getPerProjectAccounts, + getCodexMode, getEmptyResponseMaxRetries, getEmptyResponseRetryDelayMs, - getPidOffsetEnabled, + getFallbackToGpt52OnUnsupportedGpt53, + getFastSession, + getFastSessionMaxInputItems, + getFastSessionStrategy, getFetchTimeoutMs, - getStreamStallTimeoutMs, - getCodexTuiV2, - getCodexTuiColorProfile, - getCodexTuiGlyphMode, getLiveAccountSync, getLiveAccountSyncDebounceMs, getLiveAccountSyncPollMs, - getSessionAffinity, - getSessionAffinityTtlMs, - getSessionAffinityMaxEntries, - getProactiveRefreshGuardian, - getProactiveRefreshIntervalMs, - getProactiveRefreshBufferMs, getNetworkErrorCooldownMs, - getServerErrorCooldownMs, - getStorageBackupEnabled, + getPerProjectAccounts, + getPidOffsetEnabled, getPreemptiveQuotaEnabled, + getPreemptiveQuotaMaxDeferralMs, getPreemptiveQuotaRemainingPercent5h, getPreemptiveQuotaRemainingPercent7d, - getPreemptiveQuotaMaxDeferralMs, + getProactiveRefreshBufferMs, + getProactiveRefreshGuardian, + getProactiveRefreshIntervalMs, + getRateLimitToastDebounceMs, + getRetryAllAccountsMaxRetries, + getRetryAllAccountsMaxWaitMs, + getRetryAllAccountsRateLimited, + getServerErrorCooldownMs, + getSessionAffinity, + getSessionAffinityMaxEntries, + getSessionAffinityTtlMs, + getSessionRecovery, + getStorageBackupEnabled, + getStreamStallTimeoutMs, + getToastDurationMs, + getTokenRefreshSkewMs, + getUnsupportedCodexFallbackChain, + getUnsupportedCodexPolicy, loadPluginConfig, } from "./lib/config.js"; import { - AUTH_LABELS, - CODEX_BASE_URL, - DUMMY_API_KEY, - LOG_STAGES, - PLUGIN_NAME, - PROVIDER_ID, - ACCOUNT_LIMITS, + ACCOUNT_LIMITS, + AUTH_LABELS, + CODEX_BASE_URL, + DUMMY_API_KEY, + LOG_STAGES, + PLUGIN_NAME, + PROVIDER_ID, } from "./lib/constants.js"; +import { handleContextOverflow } from "./lib/context-overflow.js"; +import { + EntitlementCache, + resolveEntitlementAccountKey, +} from "./lib/entitlement-cache.js"; +import { LiveAccountSync } from "./lib/live-account-sync.js"; import { + clearCorrelationId, initLogger, - logRequest, logDebug, + logError, logInfo, + logRequest, logWarn, - logError, setCorrelationId, - clearCorrelationId, } from "./lib/logger.js"; -import { checkAndNotify } from "./lib/auto-update-checker.js"; -import { handleContextOverflow } from "./lib/context-overflow.js"; import { - AccountManager, - getAccountIdCandidates, - extractAccountEmail, - extractAccountId, - formatAccountLabel, - formatCooldown, - formatWaitTime, - resolveRuntimeRequestIdentity, - sanitizeEmail, - selectBestAccountCandidate, - shouldUpdateAccountIdFromToken, - resolveRequestAccountId, - parseRateLimitReason, - lookupCodexCliTokensByEmail, - isCodexCliSyncEnabled, - type Workspace, -} from "./lib/accounts.js"; + PreemptiveQuotaScheduler, + readQuotaSchedulerSnapshot, +} from "./lib/preemptive-quota-scheduler.js"; import { - getStoragePath, - loadAccounts, - saveAccounts, - withAccountStorageTransaction, - clearAccounts, - setStoragePath, - exportAccounts, - importAccounts, - loadFlaggedAccounts, - saveFlaggedAccounts, - clearFlaggedAccounts, - findMatchingAccountIndex, - StorageError, - formatStorageErrorHint, - setStorageBackupEnabled, - type AccountStorageV3, - type FlaggedAccountMetadataV1, -} from "./lib/storage.js"; + getModelFamily, + MODEL_FAMILIES, + prewarmCodexInstructions, +} from "./lib/prompts/codex.js"; +import { prewarmHostCodexPrompt } from "./lib/prompts/host-codex-prompt.js"; +import { + fetchCodexQuotaSnapshot, + formatQuotaSnapshotLine, +} from "./lib/quota-probe.js"; +import { + createSessionRecoveryHook, + detectErrorType, + getRecoveryToastContent, + isRecoverableError, +} from "./lib/recovery.js"; +import { RefreshGuardian } from "./lib/refresh-guardian.js"; +import { queuedRefresh } from "./lib/refresh-queue.js"; +import { + parseEnvInt, + parseFailoverMode, +} from "./lib/request/failover-config.js"; +import { + evaluateFailurePolicy, + type FailoverMode, +} from "./lib/request/failure-policy.js"; import { applyProxyCompatibleInit, createCodexHeaders, extractRequestUrl, - handleErrorResponse, - handleSuccessResponse, getUnsupportedCodexModelInfo, + handleErrorResponse, + handleSuccessResponse, + isWorkspaceDisabledError, + refreshAndUpdateToken, resolveUnsupportedCodexFallbackModel, - refreshAndUpdateToken, - rewriteUrlForCodex, + rewriteUrlForCodex, shouldRefreshToken, transformRequestForCodex, - isWorkspaceDisabledError, } from "./lib/request/fetch-helpers.js"; -import { applyFastSessionDefaults } from "./lib/request/request-transformer.js"; import { getRateLimitBackoff, RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS, resetRateLimitBackoff, } from "./lib/request/rate-limit-backoff.js"; +import { + normalizeRequestInit, + parseRequestBodyFromInit, +} from "./lib/request/request-init.js"; +import { applyFastSessionDefaults } from "./lib/request/request-transformer.js"; import { isEmptyResponse } from "./lib/request/response-handler.js"; +import { + parseRetryAfterHintMs, + sanitizeResponseHeadersForLog, +} from "./lib/request/response-metadata.js"; +import { withStreamingFailover } from "./lib/request/stream-failover.js"; +import { + createAbortableSleep, + sleepWithCountdown, +} from "./lib/request/wait-utils.js"; import { addJitter } from "./lib/rotation.js"; -import { SessionAffinityStore } from "./lib/session-affinity.js"; -import { LiveAccountSync } from "./lib/live-account-sync.js"; -import { RefreshGuardian } from "./lib/refresh-guardian.js"; import { - evaluateFailurePolicy, - type FailoverMode, -} from "./lib/request/failure-policy.js"; + clampActiveIndices, + isFlaggableFailure, +} from "./lib/runtime/account-check-helpers.js"; import { - EntitlementCache, - resolveEntitlementAccountKey, -} from "./lib/entitlement-cache.js"; + type TokenSuccessWithAccount as AccountPoolTokenSuccessWithAccount, + persistAccountPoolResults, +} from "./lib/runtime/account-pool.js"; +import { resolveAccountSelection } from "./lib/runtime/account-selection.js"; import { - PreemptiveQuotaScheduler, - readQuotaSchedulerSnapshot, -} from "./lib/preemptive-quota-scheduler.js"; -import { CapabilityPolicyStore } from "./lib/capability-policy.js"; -import { withStreamingFailover } from "./lib/request/stream-failover.js"; -import { buildTableHeader, buildTableRow, type TableOptions } from "./lib/table-formatter.js"; -import { setUiRuntimeOptions, type UiRuntimeOptions } from "./lib/ui/runtime.js"; -import { paintUiText, formatUiBadge, formatUiHeader, formatUiItem, formatUiKeyValue, formatUiSection } from "./lib/ui/format.js"; + formatRateLimitEntry, + getRateLimitResetTimeForFamily, + resolveActiveIndex, +} from "./lib/runtime/account-status.js"; +import { runBrowserOAuthFlow } from "./lib/runtime/browser-oauth-flow.js"; +import { buildManualOAuthFlow } from "./lib/runtime/manual-oauth-flow.js"; import { - getModelFamily, - getCodexInstructions, - MODEL_FAMILIES, - prewarmCodexInstructions, - type ModelFamily, -} from "./lib/prompts/codex.js"; -import { prewarmHostCodexPrompt } from "./lib/prompts/host-codex-prompt.js"; + ensureLiveAccountSyncState, + ensureRefreshGuardianState, + ensureSessionAffinityState, +} from "./lib/runtime/runtime-services.js"; +import { applyAccountStorageScopeFromConfig } from "./lib/runtime/storage-scope.js"; +import { + applyUiRuntimeFromConfig, + getStatusMarker, +} from "./lib/runtime/ui-runtime.js"; +import { SessionAffinityStore } from "./lib/session-affinity.js"; +import { registerCleanup } from "./lib/shutdown.js"; +import { + type AccountStorageV3, + clearAccounts, + clearFlaggedAccounts, + exportAccounts, + type FlaggedAccountMetadataV1, + findMatchingAccountIndex, + formatStorageErrorHint, + getStoragePath, + importAccounts, + loadAccounts, + loadFlaggedAccounts, + StorageError, + saveAccounts, + saveFlaggedAccounts, + setStorageBackupEnabled, + setStoragePath, + withAccountStorageTransaction, +} from "./lib/storage.js"; +import { + buildTableHeader, + buildTableRow, + type TableOptions, +} from "./lib/table-formatter.js"; +import { + createHashlineEditTool, + createHashlineReadTool, +} from "./lib/tools/hashline-tools.js"; import type { AccountIdSource, OAuthAuthDetails, @@ -196,16 +253,17 @@ import type { UserConfig, } from "./lib/types.js"; import { - createSessionRecoveryHook, - isRecoverableError, - detectErrorType, - getRecoveryToastContent, -} from "./lib/recovery.js"; + formatUiBadge, + formatUiHeader, + formatUiItem, + formatUiKeyValue, + formatUiSection, + paintUiText, +} from "./lib/ui/format.js"; import { - createHashlineEditTool, - createHashlineReadTool, -} from "./lib/tools/hashline-tools.js"; -import { registerCleanup } from "./lib/shutdown.js"; + setUiRuntimeOptions, + type UiRuntimeOptions, +} from "./lib/ui/runtime.js"; /** * OpenAI Codex OAuth authentication plugin for Codex CLI host runtime @@ -236,7 +294,8 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { let liveAccountSyncPath: string | null = null; let refreshGuardian: RefreshGuardian | null = null; let refreshGuardianConfigKey: string | null = null; - let sessionAffinityStore: SessionAffinityStore | null = new SessionAffinityStore(); + let sessionAffinityStore: SessionAffinityStore | null = + new SessionAffinityStore(); let sessionAffinityConfigKey: string | null = null; const entitlementCache = new EntitlementCache(); const preemptiveQuotaScheduler = new PreemptiveQuotaScheduler(); @@ -256,82 +315,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { conservative: 20_000, }; - const parseFailoverMode = (value: string | undefined): FailoverMode => { - const normalized = (value ?? "").trim().toLowerCase(); - if (normalized === "aggressive") return "aggressive"; - if (normalized === "conservative") return "conservative"; - return "balanced"; - }; - - const parseEnvInt = (value: string | undefined): number | undefined => { - if (value === undefined) return undefined; - const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) ? parsed : undefined; - }; - - const MAX_RETRY_HINT_MS = 5 * 60 * 1000; - const clampRetryHintMs = (value: number): number | null => { - if (!Number.isFinite(value)) return null; - const normalized = Math.floor(value); - if (normalized <= 0) return null; - return Math.min(normalized, MAX_RETRY_HINT_MS); - }; - - const parseRetryAfterHintMs = (headers: Headers): number | null => { - const retryAfterMsHeader = headers.get("retry-after-ms")?.trim(); - if (retryAfterMsHeader && /^\d+$/.test(retryAfterMsHeader)) { - return clampRetryHintMs(Number.parseInt(retryAfterMsHeader, 10)); - } - - const retryAfterHeader = headers.get("retry-after")?.trim(); - if (retryAfterHeader && /^\d+$/.test(retryAfterHeader)) { - return clampRetryHintMs(Number.parseInt(retryAfterHeader, 10) * 1000); - } - if (retryAfterHeader) { - const retryAtMs = Date.parse(retryAfterHeader); - if (Number.isFinite(retryAtMs)) { - return clampRetryHintMs(retryAtMs - Date.now()); - } - } - - const resetAtHeader = headers.get("x-ratelimit-reset")?.trim(); - if (resetAtHeader && /^\d+$/.test(resetAtHeader)) { - const resetRaw = Number.parseInt(resetAtHeader, 10); - const resetAtMs = resetRaw < 10_000_000_000 ? resetRaw * 1000 : resetRaw; - return clampRetryHintMs(resetAtMs - Date.now()); - } - - return null; - }; - - const sanitizeResponseHeadersForLog = (headers: Headers): Record => { - const allowed = new Set([ - "content-type", - "x-request-id", - "x-openai-request-id", - "x-codex-plan-type", - "x-codex-active-limit", - "x-codex-primary-used-percent", - "x-codex-primary-window-minutes", - "x-codex-primary-reset-at", - "x-codex-primary-reset-after-seconds", - "x-codex-secondary-used-percent", - "x-codex-secondary-window-minutes", - "x-codex-secondary-reset-at", - "x-codex-secondary-reset-after-seconds", - "retry-after", - "x-ratelimit-reset", - "x-ratelimit-reset-requests", - ]); - const sanitized: Record = {}; - for (const [rawName, rawValue] of headers.entries()) { - const name = rawName.toLowerCase(); - if (!allowed.has(name)) continue; - sanitized[name] = rawValue; - } - return sanitized; - }; - type RuntimeMetrics = { startedAt: number; totalRequests: number; @@ -374,733 +357,384 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { lastError: null, }; - type TokenSuccess = Extract; - type TokenSuccessWithAccount = TokenSuccess & { - accountIdOverride?: string; - accountIdSource?: AccountIdSource; - accountLabel?: string; + type TokenSuccess = Extract; + type TokenSuccessWithAccount = AccountPoolTokenSuccessWithAccount & { + accountIdOverride?: string; + accountIdSource?: AccountIdSource; + accountLabel?: string; workspaces?: Workspace[]; - }; - - const resolveAccountSelection = ( - tokens: TokenSuccess, - ): TokenSuccessWithAccount => { - const override = (process.env.CODEX_AUTH_ACCOUNT_ID ?? "").trim(); - if (override) { - const suffix = override.length > 6 ? override.slice(-6) : override; - logInfo(`Using account override from CODEX_AUTH_ACCOUNT_ID (id:${suffix}).`); - return { - ...tokens, - accountIdOverride: override, - accountIdSource: "manual", - accountLabel: `Override [id:${suffix}]`, - }; - } - - const candidates = getAccountIdCandidates(tokens.access, tokens.idToken); - if (candidates.length === 0) { - return tokens; - } - - // Convert candidates to workspaces - const workspaces: Workspace[] = candidates.map((c) => ({ - id: c.accountId, - name: c.label, - enabled: true, - isDefault: c.isDefault, - })); - - if (candidates.length === 1) { - const [candidate] = candidates; - if (candidate) { - return { - ...tokens, - accountIdOverride: candidate.accountId, - accountIdSource: candidate.source, - accountLabel: candidate.label, - workspaces, - }; - } - } + }; - // Auto-select the best workspace candidate without prompting. - // This honors org/default/id-token signals and avoids forcing personal token IDs. - const choice = selectBestAccountCandidate(candidates); - if (!choice) return tokens; - - return { - ...tokens, - accountIdOverride: choice.accountId, - accountIdSource: choice.source ?? "token", - accountLabel: choice.label, - workspaces, - }; - }; - - const buildManualOAuthFlow = ( - pkce: { verifier: string }, - url: string, - expectedState: string, - onSuccess?: (tokens: TokenSuccessWithAccount) => Promise, - ) => ({ - url, - method: "code" as const, - instructions: AUTH_LABELS.INSTRUCTIONS_MANUAL, - validate: (input: string): string | undefined => { - const parsed = parseAuthorizationInput(input); - if (!parsed.code) { - return `No authorization code found. Paste the full callback URL (e.g., ${REDIRECT_URI}?code=...)`; - } - if (!parsed.state) { - return "Missing OAuth state. Paste the full callback URL including both code and state parameters."; - } - if (parsed.state !== expectedState) { - return "OAuth state mismatch. Restart login and paste the callback URL generated for this login attempt."; - } - return undefined; - }, - callback: async (input: string) => { - const parsed = parseAuthorizationInput(input); - if (!parsed.code || !parsed.state) { - return { - type: "failed" as const, - reason: "invalid_response" as const, - message: "Missing authorization code or OAuth state", - }; - } - if (parsed.state !== expectedState) { - return { - type: "failed" as const, - reason: "invalid_response" as const, - message: "OAuth state mismatch. Restart login and try again.", - }; - } - const tokens = await exchangeAuthorizationCode( - parsed.code, - pkce.verifier, - REDIRECT_URI, - ); - if (tokens?.type === "success") { - const resolved = resolveAccountSelection(tokens); - if (onSuccess) { - await onSuccess(resolved); - } - return resolved; - } - return tokens?.type === "failed" - ? tokens - : { type: "failed" as const }; - }, - }); + const resolveTokenSuccessAccount = ( + tokens: TokenSuccess, + ): TokenSuccessWithAccount => + resolveAccountSelection(tokens, { + envAccountId: process.env.CODEX_AUTH_ACCOUNT_ID, + logInfo, + getAccountIdCandidates, + selectBestAccountCandidate, + }); const runOAuthFlow = async ( forceNewLogin: boolean = false, - ): Promise => { - const { pkce, state, url } = await createAuthorizationFlow({ forceNewLogin }); - logInfo(`OAuth URL: ${redactOAuthUrlForLog(url)}`); - - let serverInfo: Awaited> | null = null; - try { - serverInfo = await startLocalOAuthServer({ state }); - } catch (err) { - logDebug(`[${PLUGIN_NAME}] Failed to start OAuth server: ${(err as Error)?.message ?? String(err)}`); - serverInfo = null; - } - openBrowserUrl(url); - - if (!serverInfo || !serverInfo.ready) { - serverInfo?.close(); - const message = - `\n[${PLUGIN_NAME}] OAuth callback server failed to start. ` + - `Please retry with "${AUTH_LABELS.OAUTH_MANUAL}".\n`; - logWarn(message); - return { type: "failed" as const }; - } - - const result = await serverInfo.waitForCode(state); - serverInfo.close(); - - if (!result) { - return { type: "failed" as const, reason: "unknown" as const, message: "OAuth callback timeout or cancelled" }; - } - - return await exchangeAuthorizationCode( - result.code, - pkce.verifier, - REDIRECT_URI, - ); - }; - - const persistAccountPool = async ( - results: TokenSuccessWithAccount[], - replaceAll: boolean = false, - ): Promise => { - if (results.length === 0) return; - await withAccountStorageTransaction(async (loadedStorage, persist) => { - const now = Date.now(); - const stored = replaceAll ? null : loadedStorage; - const accounts = stored?.accounts ? [...stored.accounts] : []; - - for (const result of results) { - const accountId = result.accountIdOverride ?? extractAccountId(result.access); - const accountIdSource = - accountId - ? result.accountIdSource ?? - (result.accountIdOverride ? "manual" : "token") - : undefined; - const accountLabel = result.accountLabel; - const accountEmail = sanitizeEmail(extractAccountEmail(result.access, result.idToken)); - const existingIndex = findMatchingAccountIndex(accounts, { - accountId, - email: accountEmail, - refreshToken: result.refresh, - }, { - allowUniqueAccountIdFallbackWithoutEmail: true, - }); - - if (existingIndex === undefined) { - const initialWorkspaceIndex = - result.workspaces && result.workspaces.length > 0 - ? (() => { - if (accountId) { - const matchingWorkspaceIndex = result.workspaces.findIndex( - (workspace) => workspace.id === accountId, - ); - if (matchingWorkspaceIndex >= 0) { - return matchingWorkspaceIndex; - } - } - const firstEnabledWorkspaceIndex = result.workspaces.findIndex( - (workspace) => workspace.enabled !== false, - ); - return firstEnabledWorkspaceIndex >= 0 ? firstEnabledWorkspaceIndex : 0; - })() - : undefined; - accounts.push({ - accountId, - accountIdSource, - accountLabel, - email: accountEmail, - refreshToken: result.refresh, - accessToken: result.access, - expiresAt: result.expires, - addedAt: now, - lastUsed: now, - workspaces: result.workspaces, - currentWorkspaceIndex: initialWorkspaceIndex, - }); - continue; - } - - const existing = accounts[existingIndex]; - if (!existing) continue; - - const nextEmail = accountEmail ?? sanitizeEmail(existing.email); - const nextAccountId = accountId ?? existing.accountId; - const nextAccountIdSource = - accountId ? accountIdSource ?? existing.accountIdSource : existing.accountIdSource; - const nextAccountLabel = accountLabel ?? existing.accountLabel; - // Preserve tracked workspace state when auth refreshes do not return workspace metadata. - const mergedWorkspaces = result.workspaces - ? result.workspaces.map((newWs) => { - const existingWs = existing.workspaces?.find((w) => w.id === newWs.id); - return existingWs - ? { - ...newWs, - enabled: existingWs.enabled, - disabledAt: existingWs.disabledAt, - } - : newWs; - }) - : existing.workspaces; - const currentWorkspaceId = - existing.workspaces?.[ - typeof existing.currentWorkspaceIndex === "number" - ? existing.currentWorkspaceIndex - : 0 - ]?.id; - const nextCurrentWorkspaceIndex = - mergedWorkspaces && mergedWorkspaces.length > 0 - ? (() => { - if (currentWorkspaceId) { - const matchingWorkspaceIndex = mergedWorkspaces.findIndex( - (workspace) => workspace.id === currentWorkspaceId, - ); - if (matchingWorkspaceIndex >= 0) { - return matchingWorkspaceIndex; - } - } - const defaultWorkspaceIndex = mergedWorkspaces.findIndex( - (workspace) => workspace.isDefault === true, - ); - if (defaultWorkspaceIndex >= 0) { - return defaultWorkspaceIndex; - } - const firstEnabledWorkspaceIndex = mergedWorkspaces.findIndex( - (workspace) => workspace.enabled !== false, - ); - return firstEnabledWorkspaceIndex >= 0 ? firstEnabledWorkspaceIndex : 0; - })() - : existing.currentWorkspaceIndex; - accounts[existingIndex] = { - ...existing, - accountId: nextAccountId, - accountIdSource: nextAccountIdSource, - accountLabel: nextAccountLabel, - email: nextEmail, - refreshToken: result.refresh, - accessToken: result.access, - expiresAt: result.expires, - lastUsed: now, - workspaces: mergedWorkspaces, - currentWorkspaceIndex: nextCurrentWorkspaceIndex, - }; - } - - if (accounts.length === 0) return; - - const activeIndex = replaceAll - ? 0 - : typeof stored?.activeIndex === "number" && Number.isFinite(stored.activeIndex) - ? stored.activeIndex - : 0; - - const clampedActiveIndex = Math.max(0, Math.min(activeIndex, accounts.length - 1)); - const activeIndexByFamily: Partial> = {}; - for (const family of MODEL_FAMILIES) { - const storedFamilyIndex = stored?.activeIndexByFamily?.[family]; - const rawFamilyIndex = replaceAll - ? 0 - : typeof storedFamilyIndex === "number" && Number.isFinite(storedFamilyIndex) - ? storedFamilyIndex - : clampedActiveIndex; - activeIndexByFamily[family] = Math.max( - 0, - Math.min(Math.floor(rawFamilyIndex), accounts.length - 1), - ); - } - - await persist({ - version: 3, - accounts, - activeIndex: clampedActiveIndex, - activeIndexByFamily, - }); - }); - }; - - const showToast = async ( - message: string, - variant: "info" | "success" | "warning" | "error" = "success", - options?: { title?: string; duration?: number }, - ): Promise => { - try { - await client.tui.showToast({ - body: { - message, - variant, - ...(options?.title && { title: options.title }), - ...(options?.duration && { duration: options.duration }), - }, - }); - } catch { - // Ignore when TUI is not available. - } - }; - - const resolveActiveIndex = ( - storage: { - activeIndex: number; - activeIndexByFamily?: Partial>; - accounts: unknown[]; + ): Promise => + runBrowserOAuthFlow({ + forceNewLogin, + createAuthorizationFlow, + logInfo, + redactOAuthUrlForLog, + startLocalOAuthServer, + logDebug, + openBrowserUrl, + pluginName: PLUGIN_NAME, + authManualLabel: AUTH_LABELS.OAUTH_MANUAL, + logWarn, + exchangeAuthorizationCode, + redirectUri: REDIRECT_URI, + }); + + const persistAccountPool = async ( + results: TokenSuccessWithAccount[], + replaceAll: boolean = false, + ): Promise => + persistAccountPoolResults({ + results, + replaceAll, + modelFamilies: MODEL_FAMILIES, + withAccountStorageTransaction, + findMatchingAccountIndex, + extractAccountId, + extractAccountEmail, + sanitizeEmail, + }); + + const showToast = async ( + message: string, + variant: "info" | "success" | "warning" | "error" = "success", + options?: { title?: string; duration?: number }, + ): Promise => { + try { + await client.tui.showToast({ + body: { + message, + variant, + ...(options?.title && { title: options.title }), + ...(options?.duration && { duration: options.duration }), }, - family: ModelFamily = "codex", - ): number => { - const total = storage.accounts.length; - if (total === 0) return 0; - const rawCandidate = storage.activeIndexByFamily?.[family] ?? storage.activeIndex; - const raw = Number.isFinite(rawCandidate) ? rawCandidate : 0; - return Math.max(0, Math.min(raw, total - 1)); - }; + }); + } catch { + // Ignore when TUI is not available. + } + }; const hydrateEmails = async ( - storage: AccountStorageV3 | null, + storage: AccountStorageV3 | null, ): Promise => { - if (!storage) return storage; - const skipHydrate = - process.env.VITEST_WORKER_ID !== undefined || - process.env.NODE_ENV === "test" || - process.env.CODEX_SKIP_EMAIL_HYDRATE === "1"; - if (skipHydrate) return storage; - - const accountsCopy = storage.accounts.map((account) => - account ? { ...account } : account, - ); - const accountsToHydrate = accountsCopy.filter( - (account) => account && !account.email, - ); - if (accountsToHydrate.length === 0) return storage; - - let changed = false; - await Promise.all( - accountsToHydrate.map(async (account) => { - try { - const refreshed = await queuedRefresh(account.refreshToken); - if (refreshed.type !== "success") return; - const id = extractAccountId(refreshed.access); - const email = sanitizeEmail(extractAccountEmail(refreshed.access, refreshed.idToken)); - if ( - id && - id !== account.accountId && - shouldUpdateAccountIdFromToken(account.accountIdSource, account.accountId) - ) { - account.accountId = id; - account.accountIdSource = "token"; - changed = true; - } - if (email && email !== account.email) { - account.email = email; - changed = true; - } + if (!storage) return storage; + const skipHydrate = + process.env.VITEST_WORKER_ID !== undefined || + process.env.NODE_ENV === "test" || + process.env.CODEX_SKIP_EMAIL_HYDRATE === "1"; + if (skipHydrate) return storage; + + const accountsCopy = storage.accounts.map((account) => + account ? { ...account } : account, + ); + const accountsToHydrate = accountsCopy.filter( + (account) => account && !account.email, + ); + if (accountsToHydrate.length === 0) return storage; + + let changed = false; + await Promise.all( + accountsToHydrate.map(async (account) => { + try { + const refreshed = await queuedRefresh(account.refreshToken); + if (refreshed.type !== "success") return; + const id = extractAccountId(refreshed.access); + const email = sanitizeEmail( + extractAccountEmail(refreshed.access, refreshed.idToken), + ); + if ( + id && + id !== account.accountId && + shouldUpdateAccountIdFromToken( + account.accountIdSource, + account.accountId, + ) + ) { + account.accountId = id; + account.accountIdSource = "token"; + changed = true; + } + if (email && email !== account.email) { + account.email = email; + changed = true; + } if (refreshed.access && refreshed.access !== account.accessToken) { account.accessToken = refreshed.access; changed = true; } - if (typeof refreshed.expires === "number" && refreshed.expires !== account.expiresAt) { + if ( + typeof refreshed.expires === "number" && + refreshed.expires !== account.expiresAt + ) { account.expiresAt = refreshed.expires; changed = true; } - if (refreshed.refresh && refreshed.refresh !== account.refreshToken) { - account.refreshToken = refreshed.refresh; - changed = true; - } + if (refreshed.refresh && refreshed.refresh !== account.refreshToken) { + account.refreshToken = refreshed.refresh; + changed = true; + } } catch { logWarn(`[${PLUGIN_NAME}] Failed to hydrate email for account`); } - }), - ); - - if (changed) { - storage.accounts = accountsCopy; - await saveAccounts(storage); - } - return storage; - }; - - const getRateLimitResetTimeForFamily = ( - account: { rateLimitResetTimes?: Record }, - now: number, - family: ModelFamily, - ): number | null => { - const times = account.rateLimitResetTimes; - if (!times) return null; - - let minReset: number | null = null; - const prefix = `${family}:`; - for (const [key, value] of Object.entries(times)) { - if (typeof value !== "number") continue; - if (value <= now) continue; - if (key !== family && !key.startsWith(prefix)) continue; - if (minReset === null || value < minReset) { - minReset = value; - } - } + }), + ); - return minReset; - }; - - const formatRateLimitEntry = ( - account: { rateLimitResetTimes?: Record }, - now: number, - family: ModelFamily = "codex", - ): string | null => { - const resetAt = getRateLimitResetTimeForFamily(account, now, family); - if (typeof resetAt !== "number") return null; - const remaining = resetAt - now; - if (remaining <= 0) return null; - return `resets in ${formatWaitTime(remaining)}`; - }; - - const applyUiRuntimeFromConfig = ( - pluginConfig: ReturnType, - ): UiRuntimeOptions => { - return setUiRuntimeOptions({ - v2Enabled: getCodexTuiV2(pluginConfig), - colorProfile: getCodexTuiColorProfile(pluginConfig), - glyphMode: getCodexTuiGlyphMode(pluginConfig), - }); - }; - - const resolveUiRuntime = (): UiRuntimeOptions => { - return applyUiRuntimeFromConfig(loadPluginConfig()); - }; - - const getStatusMarker = ( - ui: UiRuntimeOptions, - status: "ok" | "warning" | "error", - ): string => { - if (!ui.v2Enabled) { - if (status === "ok") return "✓"; - if (status === "warning") return "!"; - return "✗"; - } - if (status === "ok") return ui.theme.glyphs.check; - if (status === "warning") return "!"; - return ui.theme.glyphs.cross; - }; - - const invalidateAccountManagerCache = (): void => { - cachedAccountManager = null; - accountManagerPromise = null; - }; - - const reloadAccountManagerFromDisk = async ( - authFallback?: OAuthAuthDetails, - ): Promise => { - if (accountReloadInFlight) { - return accountReloadInFlight; - } - accountReloadInFlight = (async () => { - const reloaded = await AccountManager.loadFromDisk(authFallback); - cachedAccountManager = reloaded; - accountManagerPromise = Promise.resolve(reloaded); - return reloaded; - })(); - try { - return await accountReloadInFlight; - } finally { - accountReloadInFlight = null; - } - }; - - const applyAccountStorageScope = (pluginConfig: ReturnType): void => { - const perProjectAccounts = getPerProjectAccounts(pluginConfig); - setStorageBackupEnabled(getStorageBackupEnabled(pluginConfig)); - if (isCodexCliSyncEnabled()) { - if (perProjectAccounts && !perProjectStorageWarningShown) { - perProjectStorageWarningShown = true; - logWarn( - `[${PLUGIN_NAME}] CODEX_AUTH_PER_PROJECT_ACCOUNTS is ignored while Codex CLI sync is enabled. Using global account storage.`, - ); - } - setStoragePath(null); - return; - } + if (changed) { + storage.accounts = accountsCopy; + await saveAccounts(storage); + } + return storage; + }; - setStoragePath(perProjectAccounts ? process.cwd() : null); - }; - - const ensureLiveAccountSync = async ( - pluginConfig: ReturnType, - authFallback?: OAuthAuthDetails, - ): Promise => { - if (!getLiveAccountSync(pluginConfig)) { - if (liveAccountSync) { - liveAccountSync.stop(); - liveAccountSync = null; - liveAccountSyncPath = null; - } - return; - } + const resolveUiRuntime = (): UiRuntimeOptions => { + return applyUiRuntimeFromConfig(loadPluginConfig(), setUiRuntimeOptions); + }; + + const invalidateAccountManagerCache = (): void => { + cachedAccountManager = null; + accountManagerPromise = null; + }; + + const reloadAccountManagerFromDisk = async ( + authFallback?: OAuthAuthDetails, + ): Promise => { + if (accountReloadInFlight) { + return accountReloadInFlight; + } + accountReloadInFlight = (async () => { + const reloaded = await AccountManager.loadFromDisk(authFallback); + cachedAccountManager = reloaded; + accountManagerPromise = Promise.resolve(reloaded); + return reloaded; + })(); + try { + return await accountReloadInFlight; + } finally { + accountReloadInFlight = null; + } + }; - const targetPath = getStoragePath(); - if (!liveAccountSync) { - liveAccountSync = new LiveAccountSync( + const applyAccountStorageScope = ( + pluginConfig: ReturnType, + ): void => + applyAccountStorageScopeFromConfig(pluginConfig, { + getPerProjectAccounts, + getStorageBackupEnabled, + setStorageBackupEnabled, + isCodexCliSyncEnabled, + getWarningShown: () => perProjectStorageWarningShown, + setWarningShown: (shown) => { + perProjectStorageWarningShown = shown; + }, + logWarn, + pluginName: PLUGIN_NAME, + setStoragePath, + cwd: () => process.cwd(), + }); + + const ensureLiveAccountSync = async ( + pluginConfig: ReturnType, + authFallback?: OAuthAuthDetails, + ): Promise => { + const next = await ensureLiveAccountSyncState({ + enabled: getLiveAccountSync(pluginConfig), + targetPath: getStoragePath(), + currentSync: liveAccountSync, + currentPath: liveAccountSyncPath, + authFallback, + createSync: (oauthFallback) => + new LiveAccountSync( async () => { - await reloadAccountManagerFromDisk(authFallback); + await reloadAccountManagerFromDisk(oauthFallback); }, { debounceMs: getLiveAccountSyncDebounceMs(pluginConfig), pollIntervalMs: getLiveAccountSyncPollMs(pluginConfig), }, - ); - registerCleanup(() => { - liveAccountSync?.stop(); - }); - } + ), + registerCleanup, + logWarn, + pluginName: PLUGIN_NAME, + }); + liveAccountSync = next.liveAccountSync; + liveAccountSyncPath = next.liveAccountSyncPath; + }; - if (liveAccountSyncPath !== targetPath) { - let switched = false; - for (let attempt = 0; attempt < 3; attempt += 1) { - try { - await liveAccountSync.syncToPath(targetPath); - liveAccountSyncPath = targetPath; - switched = true; - break; - } catch (error) { - const code = (error as NodeJS.ErrnoException | undefined)?.code; - if (code !== "EBUSY" && code !== "EPERM") { - throw error; - } - await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); - } - } - if (!switched) { - logWarn( - `[${PLUGIN_NAME}] Live account sync path switch failed due to transient filesystem locks; keeping previous watcher.`, - ); - } - } + const ensureRefreshGuardian = ( + pluginConfig: ReturnType, + ): void => { + const next = ensureRefreshGuardianState({ + enabled: getProactiveRefreshGuardian(pluginConfig), + intervalMs: getProactiveRefreshIntervalMs(pluginConfig), + bufferMs: getProactiveRefreshBufferMs(pluginConfig), + currentGuardian: refreshGuardian, + currentConfigKey: refreshGuardianConfigKey, + createGuardian: ({ intervalMs, bufferMs }) => + new RefreshGuardian(() => cachedAccountManager, { + intervalMs, + bufferMs, + }), + registerCleanup, + }); + refreshGuardian = next.refreshGuardian; + refreshGuardianConfigKey = next.refreshGuardianConfigKey; + }; + + const ensureSessionAffinity = ( + pluginConfig: ReturnType, + ): void => { + const next = ensureSessionAffinityState({ + enabled: getSessionAffinity(pluginConfig), + ttlMs: getSessionAffinityTtlMs(pluginConfig), + maxEntries: getSessionAffinityMaxEntries(pluginConfig), + currentStore: sessionAffinityStore, + currentConfigKey: sessionAffinityConfigKey, + createStore: ({ ttlMs, maxEntries }) => + new SessionAffinityStore({ ttlMs, maxEntries }), + }); + sessionAffinityStore = next.sessionAffinityStore; + sessionAffinityConfigKey = next.sessionAffinityConfigKey; }; - const ensureRefreshGuardian = ( - pluginConfig: ReturnType, - ): void => { - if (!getProactiveRefreshGuardian(pluginConfig)) { - if (refreshGuardian) { - refreshGuardian.stop(); - refreshGuardian = null; - refreshGuardianConfigKey = null; + const applyPreemptiveQuotaSettings = ( + pluginConfig: ReturnType, + ): void => { + preemptiveQuotaScheduler.configure({ + enabled: getPreemptiveQuotaEnabled(pluginConfig), + remainingPercentThresholdPrimary: + getPreemptiveQuotaRemainingPercent5h(pluginConfig), + remainingPercentThresholdSecondary: + getPreemptiveQuotaRemainingPercent7d(pluginConfig), + maxDeferralMs: getPreemptiveQuotaMaxDeferralMs(pluginConfig), + }); + }; + + // Event handler for session recovery and account selection + const eventHandler = async (input: { + event: { type: string; properties?: unknown }; + }) => { + try { + const { event } = input; + // Handle TUI account selection events + // Accepts generic selection events with an index property + if ( + event.type === "account.select" || + event.type === "openai.account.select" + ) { + const props = event.properties as { + index?: number; + accountIndex?: number; + provider?: string; + }; + // Filter by provider if specified + if ( + props.provider && + props.provider !== "openai" && + props.provider !== PROVIDER_ID + ) { + return; } - return; - } - const intervalMs = getProactiveRefreshIntervalMs(pluginConfig); - const bufferMs = getProactiveRefreshBufferMs(pluginConfig); - const configKey = `${intervalMs}:${bufferMs}`; - if (refreshGuardian && refreshGuardianConfigKey === configKey) return; + const index = props.index ?? props.accountIndex; + if (typeof index === "number") { + const storage = await loadAccounts(); + if (!storage || index < 0 || index >= storage.accounts.length) { + return; + } - if (refreshGuardian) { - refreshGuardian.stop(); - } - refreshGuardian = new RefreshGuardian( - () => cachedAccountManager, - { intervalMs, bufferMs }, - ); - refreshGuardianConfigKey = configKey; - refreshGuardian.start(); - registerCleanup(() => { - refreshGuardian?.stop(); - }); - }; - - const ensureSessionAffinity = ( - pluginConfig: ReturnType, - ): void => { - if (!getSessionAffinity(pluginConfig)) { - sessionAffinityStore = null; - sessionAffinityConfigKey = null; - return; - } + const now = Date.now(); + const account = storage.accounts[index]; + if (account) { + account.lastUsed = now; + account.lastSwitchReason = "rotation"; + } + storage.activeIndex = index; + storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; + for (const family of MODEL_FAMILIES) { + storage.activeIndexByFamily[family] = index; + } - const ttlMs = getSessionAffinityTtlMs(pluginConfig); - const maxEntries = getSessionAffinityMaxEntries(pluginConfig); - const configKey = `${ttlMs}:${maxEntries}`; - if (sessionAffinityStore && sessionAffinityConfigKey === configKey) return; - sessionAffinityStore = new SessionAffinityStore({ ttlMs, maxEntries }); - sessionAffinityConfigKey = configKey; - }; - - const applyPreemptiveQuotaSettings = ( - pluginConfig: ReturnType, - ): void => { - preemptiveQuotaScheduler.configure({ - enabled: getPreemptiveQuotaEnabled(pluginConfig), - remainingPercentThresholdPrimary: getPreemptiveQuotaRemainingPercent5h(pluginConfig), - remainingPercentThresholdSecondary: getPreemptiveQuotaRemainingPercent7d(pluginConfig), - maxDeferralMs: getPreemptiveQuotaMaxDeferralMs(pluginConfig), - }); - }; - - // Event handler for session recovery and account selection - const eventHandler = async (input: { event: { type: string; properties?: unknown } }) => { - try { - const { event } = input; - // Handle TUI account selection events - // Accepts generic selection events with an index property - if ( - event.type === "account.select" || - event.type === "openai.account.select" - ) { - const props = event.properties as { index?: number; accountIndex?: number; provider?: string }; - // Filter by provider if specified - if (props.provider && props.provider !== "openai" && props.provider !== PROVIDER_ID) { - return; - } - - const index = props.index ?? props.accountIndex; - if (typeof index === "number") { - const storage = await loadAccounts(); - if (!storage || index < 0 || index >= storage.accounts.length) { - return; - } - - const now = Date.now(); - const account = storage.accounts[index]; - if (account) { - account.lastUsed = now; - account.lastSwitchReason = "rotation"; - } - storage.activeIndex = index; - storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; - for (const family of MODEL_FAMILIES) { - storage.activeIndexByFamily[family] = index; - } - - await saveAccounts(storage); - if (cachedAccountManager) { - await cachedAccountManager.syncCodexCliActiveSelectionForIndex(index); - } - lastCodexCliActiveSyncIndex = index; + await saveAccounts(storage); + if (cachedAccountManager) { + await cachedAccountManager.syncCodexCliActiveSelectionForIndex( + index, + ); + } + lastCodexCliActiveSyncIndex = index; - // Reload manager from disk so we don't overwrite newer rotated - // refresh tokens with stale in-memory state. - if (cachedAccountManager) { - await reloadAccountManagerFromDisk(); - } + // Reload manager from disk so we don't overwrite newer rotated + // refresh tokens with stale in-memory state. + if (cachedAccountManager) { + await reloadAccountManagerFromDisk(); + } - await showToast(`Switched to account ${index + 1}`, "info"); - } - } - } catch (error) { - logDebug(`[${PLUGIN_NAME}] Event handler error: ${error instanceof Error ? error.message : String(error)}`); - } - }; + await showToast(`Switched to account ${index + 1}`, "info"); + } + } + } catch (error) { + logDebug( + `[${PLUGIN_NAME}] Event handler error: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }; - // Initialize runtime UI settings once on plugin load; auth/tools refresh this dynamically. - resolveUiRuntime(); + // Initialize runtime UI settings once on plugin load; auth/tools refresh this dynamically. + resolveUiRuntime(); - return { - event: eventHandler, - auth: { + return { + event: eventHandler, + auth: { provider: PROVIDER_ID, /** * Loader function that configures OAuth authentication and request handling * * This function: - * 1. Validates OAuth authentication - * 2. Loads multi-account pool from disk (fallback to current auth) - * 3. Loads user configuration from runtime model config - * 4. Fetches Codex system instructions from GitHub (cached) - * 5. Returns SDK configuration with custom fetch implementation + * 1. Validates OAuth authentication + * 2. Loads multi-account pool from disk (fallback to current auth) + * 3. Loads user configuration from runtime model config + * 4. Fetches Codex system instructions from GitHub (cached) + * 5. Returns SDK configuration with custom fetch implementation * * @param getAuth - Function to retrieve current auth state * @param provider - Provider configuration from runtime model config * @returns SDK configuration object or empty object for non-OAuth auth */ - async loader(getAuth: () => Promise, provider: unknown) { - const auth = await getAuth(); - const pluginConfig = loadPluginConfig(); - applyUiRuntimeFromConfig(pluginConfig); - applyAccountStorageScope(pluginConfig); - ensureSessionAffinity(pluginConfig); - ensureRefreshGuardian(pluginConfig); - applyPreemptiveQuotaSettings(pluginConfig); - - // Only handle OAuth auth type, skip API key auth - if (auth.type !== "oauth") { - return {}; - } + async loader(getAuth: () => Promise, provider: unknown) { + const auth = await getAuth(); + const pluginConfig = loadPluginConfig(); + applyUiRuntimeFromConfig(pluginConfig, setUiRuntimeOptions); + applyAccountStorageScope(pluginConfig); + ensureSessionAffinity(pluginConfig); + ensureRefreshGuardian(pluginConfig); + applyPreemptiveQuotaSettings(pluginConfig); + + // Only handle OAuth auth type, skip API key auth + if (auth.type !== "oauth") { + return {}; + } - // Prefer multi-account auth metadata when available, but still handle - // plain OAuth credentials (for legacy runtime versions that inject internal - // Codex auth first and omit the multiAccount marker). - const authWithMulti = auth as typeof auth & { multiAccount?: boolean }; - if (!authWithMulti.multiAccount) { - logDebug( - `[${PLUGIN_NAME}] Auth is missing multiAccount marker; continuing with single-account compatibility mode`, - ); - } + // Prefer multi-account auth metadata when available, but still handle + // plain OAuth credentials (for legacy runtime versions that inject internal + // Codex auth first and omit the multiAccount marker). + const authWithMulti = auth as typeof auth & { multiAccount?: boolean }; + if (!authWithMulti.multiAccount) { + logDebug( + `[${PLUGIN_NAME}] Auth is missing multiAccount marker; continuing with single-account compatibility mode`, + ); + } // Acquire mutex for thread-safe initialization // Use while loop to handle multiple concurrent waiters correctly while (loaderMutex) { @@ -1121,11 +755,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { reloadAccountManagerFromDisk(auth as OAuthAuthDetails); let accountManager = await managerPromise; cachedAccountManager = accountManager; - const refreshToken = - auth.type === "oauth" ? auth.refresh : ""; + const refreshToken = auth.type === "oauth" ? auth.refresh : ""; const needsPersist = - refreshToken && - !accountManager.hasRefreshToken(refreshToken); + refreshToken && !accountManager.hasRefreshToken(refreshToken); if (needsPersist) { await accountManager.saveToDisk(); } @@ -1136,207 +768,171 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ); return {}; } - // Extract user configuration (global + per-model options) - const providerConfig = provider as - | { options?: Record; models?: UserConfig["models"] } - | undefined; - const userConfig: UserConfig = { - global: providerConfig?.options || {}, - models: providerConfig?.models || {}, - }; - - // Load plugin configuration and determine CODEX_MODE - // Priority: CODEX_MODE env var > config file > default (true) - const codexMode = getCodexMode(pluginConfig); - const fastSessionEnabled = getFastSession(pluginConfig); - const fastSessionStrategy = getFastSessionStrategy(pluginConfig); - const fastSessionMaxInputItems = getFastSessionMaxInputItems(pluginConfig); - const tokenRefreshSkewMs = getTokenRefreshSkewMs(pluginConfig); - const rateLimitToastDebounceMs = getRateLimitToastDebounceMs(pluginConfig); - const retryAllAccountsRateLimited = getRetryAllAccountsRateLimited(pluginConfig); - const retryAllAccountsMaxWaitMs = getRetryAllAccountsMaxWaitMs(pluginConfig); - const retryAllAccountsMaxRetries = getRetryAllAccountsMaxRetries(pluginConfig); - const unsupportedCodexPolicy = getUnsupportedCodexPolicy(pluginConfig); - const fallbackOnUnsupportedCodexModel = unsupportedCodexPolicy === "fallback"; - const fallbackToGpt52OnUnsupportedGpt53 = - getFallbackToGpt52OnUnsupportedGpt53(pluginConfig); - const unsupportedCodexFallbackChain = - getUnsupportedCodexFallbackChain(pluginConfig); - const toastDurationMs = getToastDurationMs(pluginConfig); - const fetchTimeoutMs = getFetchTimeoutMs(pluginConfig); - const streamStallTimeoutMs = getStreamStallTimeoutMs(pluginConfig); - const networkErrorCooldownMs = getNetworkErrorCooldownMs(pluginConfig); - const serverErrorCooldownMs = getServerErrorCooldownMs(pluginConfig); - const failoverMode = parseFailoverMode(process.env.CODEX_AUTH_FAILOVER_MODE); - const streamFailoverMax = Math.max( - 0, - parseEnvInt(process.env.CODEX_AUTH_STREAM_FAILOVER_MAX) ?? - STREAM_FAILOVER_MAX_BY_MODE[failoverMode], - ); - const streamFailoverSoftTimeoutMs = Math.max( - 1_000, - parseEnvInt(process.env.CODEX_AUTH_STREAM_STALL_SOFT_TIMEOUT_MS) ?? - STREAM_FAILOVER_SOFT_TIMEOUT_BY_MODE[failoverMode], - ); - const streamFailoverHardTimeoutMs = Math.max( - streamFailoverSoftTimeoutMs, - parseEnvInt(process.env.CODEX_AUTH_STREAM_STALL_HARD_TIMEOUT_MS) ?? - streamStallTimeoutMs, - ); - const maxSameAccountRetries = - failoverMode === "conservative" ? 2 : failoverMode === "balanced" ? 1 : 0; - - const sessionRecoveryEnabled = getSessionRecovery(pluginConfig); - const autoResumeEnabled = getAutoResume(pluginConfig); - const emptyResponseMaxRetries = getEmptyResponseMaxRetries(pluginConfig); - const emptyResponseRetryDelayMs = getEmptyResponseRetryDelayMs(pluginConfig); - const pidOffsetEnabled = getPidOffsetEnabled(pluginConfig); - const effectiveUserConfig = fastSessionEnabled - ? applyFastSessionDefaults(userConfig) - : userConfig; - if (fastSessionEnabled) { - logDebug("Fast session mode enabled", { - reasoningEffort: "none/low", - reasoningSummary: "auto", - textVerbosity: "low", - fastSessionStrategy, - fastSessionMaxInputItems, - }); - } + // Extract user configuration (global + per-model options) + const providerConfig = provider as + | { + options?: Record; + models?: UserConfig["models"]; + } + | undefined; + const userConfig: UserConfig = { + global: providerConfig?.options || {}, + models: providerConfig?.models || {}, + }; - const prewarmEnabled = - process.env.CODEX_AUTH_PREWARM !== "0" && - process.env.VITEST !== "true" && - process.env.NODE_ENV !== "test"; - - if (!startupPrewarmTriggered && prewarmEnabled) { - startupPrewarmTriggered = true; - const configuredModels = Object.keys(userConfig.models ?? {}); - prewarmCodexInstructions(configuredModels); - if (codexMode) { - prewarmHostCodexPrompt(); + // Load plugin configuration and determine CODEX_MODE + // Priority: CODEX_MODE env var > config file > default (true) + const codexMode = getCodexMode(pluginConfig); + const fastSessionEnabled = getFastSession(pluginConfig); + const fastSessionStrategy = getFastSessionStrategy(pluginConfig); + const fastSessionMaxInputItems = + getFastSessionMaxInputItems(pluginConfig); + const tokenRefreshSkewMs = getTokenRefreshSkewMs(pluginConfig); + const rateLimitToastDebounceMs = + getRateLimitToastDebounceMs(pluginConfig); + const retryAllAccountsRateLimited = + getRetryAllAccountsRateLimited(pluginConfig); + const retryAllAccountsMaxWaitMs = + getRetryAllAccountsMaxWaitMs(pluginConfig); + const retryAllAccountsMaxRetries = + getRetryAllAccountsMaxRetries(pluginConfig); + const unsupportedCodexPolicy = + getUnsupportedCodexPolicy(pluginConfig); + const fallbackOnUnsupportedCodexModel = + unsupportedCodexPolicy === "fallback"; + const fallbackToGpt52OnUnsupportedGpt53 = + getFallbackToGpt52OnUnsupportedGpt53(pluginConfig); + const unsupportedCodexFallbackChain = + getUnsupportedCodexFallbackChain(pluginConfig); + const toastDurationMs = getToastDurationMs(pluginConfig); + const fetchTimeoutMs = getFetchTimeoutMs(pluginConfig); + const streamStallTimeoutMs = getStreamStallTimeoutMs(pluginConfig); + const networkErrorCooldownMs = + getNetworkErrorCooldownMs(pluginConfig); + const serverErrorCooldownMs = getServerErrorCooldownMs(pluginConfig); + const failoverMode = parseFailoverMode( + process.env.CODEX_AUTH_FAILOVER_MODE, + ); + const streamFailoverMax = Math.max( + 0, + parseEnvInt(process.env.CODEX_AUTH_STREAM_FAILOVER_MAX) ?? + STREAM_FAILOVER_MAX_BY_MODE[failoverMode], + ); + const streamFailoverSoftTimeoutMs = Math.max( + 1_000, + parseEnvInt(process.env.CODEX_AUTH_STREAM_STALL_SOFT_TIMEOUT_MS) ?? + STREAM_FAILOVER_SOFT_TIMEOUT_BY_MODE[failoverMode], + ); + const streamFailoverHardTimeoutMs = Math.max( + streamFailoverSoftTimeoutMs, + parseEnvInt(process.env.CODEX_AUTH_STREAM_STALL_HARD_TIMEOUT_MS) ?? + streamStallTimeoutMs, + ); + const maxSameAccountRetries = + failoverMode === "conservative" + ? 2 + : failoverMode === "balanced" + ? 1 + : 0; + + const sessionRecoveryEnabled = getSessionRecovery(pluginConfig); + const autoResumeEnabled = getAutoResume(pluginConfig); + const emptyResponseMaxRetries = + getEmptyResponseMaxRetries(pluginConfig); + const emptyResponseRetryDelayMs = + getEmptyResponseRetryDelayMs(pluginConfig); + const pidOffsetEnabled = getPidOffsetEnabled(pluginConfig); + const effectiveUserConfig = fastSessionEnabled + ? applyFastSessionDefaults(userConfig) + : userConfig; + if (fastSessionEnabled) { + logDebug("Fast session mode enabled", { + reasoningEffort: "none/low", + reasoningSummary: "auto", + textVerbosity: "low", + fastSessionStrategy, + fastSessionMaxInputItems, + }); } - } - - const recoveryHook = sessionRecoveryEnabled - ? createSessionRecoveryHook( - { client, directory: process.cwd() }, - { sessionRecovery: true, autoResume: autoResumeEnabled } - ) - : null; - - checkAndNotify(async (message, variant) => { - await showToast(message, variant); - }).catch((err) => { - logDebug(`Update check failed: ${err instanceof Error ? err.message : String(err)}`); - }); - - - // Return SDK configuration - return { - apiKey: DUMMY_API_KEY, - baseURL: CODEX_BASE_URL, - /** - * Custom fetch implementation for Codex API - * - * Handles: - * - Token refresh when expired - * - URL rewriting for Codex backend - * - Request body transformation - * - OAuth header injection - * - SSE to JSON conversion for non-tool requests - * - Error handling and logging - * - * @param input - Request URL or Request object - * @param init - Request options - * @returns Response from Codex API - */ - async fetch( - input: Request | string | URL, - init?: RequestInit, - ): Promise { - try { - if (cachedAccountManager && cachedAccountManager !== accountManager) { - accountManager = cachedAccountManager; - } - - // Step 1: Extract and rewrite URL for Codex backend - const originalUrl = extractRequestUrl(input); - const url = rewriteUrlForCodex(originalUrl); - - // Step 3: Transform request body with model-specific Codex instructions - // Instructions are fetched per model family (codex-max, codex, gpt-5.1) - // Capture original stream value before transformation - // generateText() sends no stream field, streamText() sends stream=true - const normalizeRequestInit = async ( - requestInput: Request | string | URL, - requestInit: RequestInit | undefined, - ): Promise => { - if (requestInit) return requestInit; - if (!(requestInput instanceof Request)) return requestInit; - - const method = requestInput.method || "GET"; - const normalized: RequestInit = { - method, - headers: new Headers(requestInput.headers), - }; - - if (method !== "GET" && method !== "HEAD") { - try { - const bodyText = await requestInput.clone().text(); - if (bodyText) { - normalized.body = bodyText; - } - } catch { - // Body may be unreadable; proceed without it. - } - } - - return normalized; - }; - - const parseRequestBodyFromInit = async ( - body: unknown, - ): Promise> => { - if (!body) return {}; - try { - if (typeof body === "string") { - return JSON.parse(body) as Record; - } - - if (body instanceof Uint8Array) { - return JSON.parse(new TextDecoder().decode(body)) as Record; - } + const prewarmEnabled = + process.env.CODEX_AUTH_PREWARM !== "0" && + process.env.VITEST !== "true" && + process.env.NODE_ENV !== "test"; + + if (!startupPrewarmTriggered && prewarmEnabled) { + startupPrewarmTriggered = true; + const configuredModels = Object.keys(userConfig.models ?? {}); + prewarmCodexInstructions(configuredModels); + if (codexMode) { + prewarmHostCodexPrompt(); + } + } - if (body instanceof ArrayBuffer) { - return JSON.parse(new TextDecoder().decode(new Uint8Array(body))) as Record; - } + const recoveryHook = sessionRecoveryEnabled + ? createSessionRecoveryHook( + { client, directory: process.cwd() }, + { sessionRecovery: true, autoResume: autoResumeEnabled }, + ) + : null; - if (ArrayBuffer.isView(body)) { - const view = new Uint8Array( - body.buffer, - body.byteOffset, - body.byteLength, - ); - return JSON.parse(new TextDecoder().decode(view)) as Record; - } + checkAndNotify(async (message, variant) => { + await showToast(message, variant); + }).catch((err) => { + logDebug( + `Update check failed: ${err instanceof Error ? err.message : String(err)}`, + ); + }); - if (typeof Blob !== "undefined" && body instanceof Blob) { - return JSON.parse(await body.text()) as Record; - } - } catch { - logWarn("Failed to parse request body, using empty object"); - } + // Return SDK configuration + return { + apiKey: DUMMY_API_KEY, + baseURL: CODEX_BASE_URL, + /** + * Custom fetch implementation for Codex API + * + * Handles: + * - Token refresh when expired + * - URL rewriting for Codex backend + * - Request body transformation + * - OAuth header injection + * - SSE to JSON conversion for non-tool requests + * - Error handling and logging + * + * @param input - Request URL or Request object + * @param init - Request options + * @returns Response from Codex API + */ + async fetch( + input: Request | string | URL, + init?: RequestInit, + ): Promise { + try { + if ( + cachedAccountManager && + cachedAccountManager !== accountManager + ) { + accountManager = cachedAccountManager; + } - return {}; - }; + // Step 1: Extract and rewrite URL for Codex backend + const originalUrl = extractRequestUrl(input); + const url = rewriteUrlForCodex(originalUrl); + // Step 3: Transform request body with model-specific Codex instructions + // Instructions are fetched per model family (codex-max, codex, gpt-5.1) + // Capture original stream value before transformation + // generateText() sends no stream field, streamText() sends stream=true const baseInit = await normalizeRequestInit(input, init); - const originalBody = await parseRequestBodyFromInit(baseInit?.body); + const originalBody = await parseRequestBodyFromInit( + baseInit?.body, + logWarn, + ); const isStreaming = originalBody.stream === true; const parsedBody = - Object.keys(originalBody).length > 0 ? originalBody : undefined; + Object.keys(originalBody).length > 0 + ? originalBody + : undefined; const transformation = await transformRequestForCodex( baseInit, @@ -1350,1705 +946,1663 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { fastSessionMaxInputItems, }, ); - let requestInit = transformation?.updatedInit ?? baseInit; - let transformedBody: RequestBody | undefined = transformation?.body; - const promptCacheKey = transformedBody?.prompt_cache_key; - let model = transformedBody?.model; - let modelFamily = model ? getModelFamily(model) : "gpt-5.1"; - let quotaKey = model ? `${modelFamily}:${model}` : modelFamily; - const threadIdCandidate = - (process.env.CODEX_THREAD_ID ?? promptCacheKey ?? "") - .toString() - .trim() || undefined; - const sessionAffinityKey = threadIdCandidate ?? promptCacheKey ?? null; - const effectivePromptCacheKey = - (sessionAffinityKey ?? promptCacheKey ?? "").toString().trim() || undefined; - const preferredSessionAccountIndex = sessionAffinityStore?.getPreferredAccountIndex( - sessionAffinityKey, - ); - sessionAffinityStore?.prune(); - const requestCorrelationId = setCorrelationId( - threadIdCandidate ? `${threadIdCandidate}:${Date.now()}` : undefined, - ); - runtimeMetrics.lastRequestAt = Date.now(); - - const abortSignal = requestInit?.signal ?? init?.signal ?? null; - const sleep = (ms: number): Promise => - new Promise((resolve, reject) => { - if (abortSignal?.aborted) { - reject(new Error("Aborted")); - return; - } - - const timeout = setTimeout(() => { - cleanup(); - resolve(); - }, ms); - - const onAbort = () => { - cleanup(); - reject(new Error("Aborted")); - }; - - const cleanup = () => { - clearTimeout(timeout); - abortSignal?.removeEventListener("abort", onAbort); - }; - - abortSignal?.addEventListener("abort", onAbort, { once: true }); - }); + let requestInit = transformation?.updatedInit ?? baseInit; + let transformedBody: RequestBody | undefined = + transformation?.body; + const promptCacheKey = transformedBody?.prompt_cache_key; + let model = transformedBody?.model; + let modelFamily = model ? getModelFamily(model) : "gpt-5.1"; + let quotaKey = model ? `${modelFamily}:${model}` : modelFamily; + const threadIdCandidate = + (process.env.CODEX_THREAD_ID ?? promptCacheKey ?? "") + .toString() + .trim() || undefined; + const sessionAffinityKey = + threadIdCandidate ?? promptCacheKey ?? null; + const effectivePromptCacheKey = + (sessionAffinityKey ?? promptCacheKey ?? "") + .toString() + .trim() || undefined; + const preferredSessionAccountIndex = + sessionAffinityStore?.getPreferredAccountIndex( + sessionAffinityKey, + ); + sessionAffinityStore?.prune(); + const requestCorrelationId = setCorrelationId( + threadIdCandidate + ? `${threadIdCandidate}:${Date.now()}` + : undefined, + ); + runtimeMetrics.lastRequestAt = Date.now(); - const sleepWithCountdown = async ( - totalMs: number, - message: string, - intervalMs: number = 5000, - ): Promise => { - const startTime = Date.now(); - const endTime = startTime + totalMs; - - while (Date.now() < endTime) { - if (abortSignal?.aborted) { - throw new Error("Aborted"); - } - - const remaining = Math.max(0, endTime - Date.now()); - const waitLabel = formatWaitTime(remaining); - await showToast( - `${message} (${waitLabel} remaining)`, - "warning", - { duration: Math.min(intervalMs + 1000, toastDurationMs) }, - ); - - const sleepTime = Math.min(intervalMs, remaining); - if (sleepTime > 0) { - await sleep(sleepTime); - } else { - break; - } - } - }; + const abortSignal = requestInit?.signal ?? init?.signal ?? null; + const sleep = createAbortableSleep(abortSignal); - let allRateLimitedRetries = 0; - let emptyResponseRetries = 0; - const attemptedUnsupportedFallbackModels = new Set(); - if (model) { - attemptedUnsupportedFallbackModels.add(model); - } + let allRateLimitedRetries = 0; + let emptyResponseRetries = 0; + const attemptedUnsupportedFallbackModels = new Set(); + if (model) { + attemptedUnsupportedFallbackModels.add(model); + } - while (true) { - const accountCount = accountManager.getAccountCount(); - const attempted = new Set(); - let restartAccountTraversalWithFallback = false; - let retryNextAccountBeforeFallback = false; - let usedPreferredSessionAccount = false; - const capabilityBoostByAccount: Record = {}; - type AccountSnapshotCandidate = { - index: number; - accountId?: string; - email?: string; - }; - const accountSnapshotSource = accountManager as { - getAccountsSnapshot?: () => AccountSnapshotCandidate[]; - getAccountByIndex?: (index: number) => AccountSnapshotCandidate | null; - }; - const accountSnapshotList = - typeof accountSnapshotSource.getAccountsSnapshot === "function" - ? accountSnapshotSource.getAccountsSnapshot() ?? [] - : []; - if ( - accountSnapshotList.length === 0 && - typeof accountSnapshotSource.getAccountByIndex === "function" + while (true) { + const accountCount = accountManager.getAccountCount(); + const attempted = new Set(); + let restartAccountTraversalWithFallback = false; + let retryNextAccountBeforeFallback = false; + let usedPreferredSessionAccount = false; + const capabilityBoostByAccount: Record = {}; + type AccountSnapshotCandidate = { + index: number; + accountId?: string; + email?: string; + }; + const accountSnapshotSource = accountManager as { + getAccountsSnapshot?: () => AccountSnapshotCandidate[]; + getAccountByIndex?: ( + index: number, + ) => AccountSnapshotCandidate | null; + }; + const accountSnapshotList = + typeof accountSnapshotSource.getAccountsSnapshot === + "function" + ? (accountSnapshotSource.getAccountsSnapshot() ?? []) + : []; + if ( + accountSnapshotList.length === 0 && + typeof accountSnapshotSource.getAccountByIndex === + "function" + ) { + for ( + let accountSnapshotIndex = 0; + accountSnapshotIndex < accountCount; + accountSnapshotIndex += 1 ) { - for ( - let accountSnapshotIndex = 0; - accountSnapshotIndex < accountCount; - accountSnapshotIndex += 1 - ) { - const candidate = accountSnapshotSource.getAccountByIndex( + const candidate = + accountSnapshotSource.getAccountByIndex( accountSnapshotIndex, ); - if (candidate) { - accountSnapshotList.push(candidate); - } + if (candidate) { + accountSnapshotList.push(candidate); } } - for (const candidate of accountSnapshotList) { - const accountKey = resolveEntitlementAccountKey(candidate); - capabilityBoostByAccount[candidate.index] = capabilityPolicyStore.getBoost( + } + for (const candidate of accountSnapshotList) { + const accountKey = resolveEntitlementAccountKey(candidate); + capabilityBoostByAccount[candidate.index] = + capabilityPolicyStore.getBoost( accountKey, model ?? modelFamily, ); - } - -accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { - let account = null; - if ( - !usedPreferredSessionAccount && - typeof preferredSessionAccountIndex === "number" - ) { - usedPreferredSessionAccount = true; - if ( - accountManager.isAccountAvailableForFamily( - preferredSessionAccountIndex, - modelFamily, - model, - ) - ) { - account = accountManager.getAccountByIndex(preferredSessionAccountIndex); - if (account) { - account.lastUsed = Date.now(); - accountManager.markSwitched(account, "rotation", modelFamily); - } - } else { - sessionAffinityStore?.forgetSession(sessionAffinityKey); - } - } + } - if (!account) { - account = accountManager.getCurrentOrNextForFamilyHybrid(modelFamily, model, { - pidOffsetEnabled, - scoreBoostByAccount: capabilityBoostByAccount, - }); - } - if (!account || attempted.has(account.index)) { - break; - } - attempted.add(account.index); - // Log account selection for debugging rotation - logDebug( - `Using account ${account.index + 1}/${accountCount}: ${account.email ?? "unknown"} for ${modelFamily}`, - ); + accountAttemptLoop: while ( + attempted.size < Math.max(1, accountCount) + ) { + let account = null; + if ( + !usedPreferredSessionAccount && + typeof preferredSessionAccountIndex === "number" + ) { + usedPreferredSessionAccount = true; + if ( + accountManager.isAccountAvailableForFamily( + preferredSessionAccountIndex, + modelFamily, + model, + ) + ) { + account = accountManager.getAccountByIndex( + preferredSessionAccountIndex, + ); + if (account) { + account.lastUsed = Date.now(); + accountManager.markSwitched( + account, + "rotation", + modelFamily, + ); + } + } else { + sessionAffinityStore?.forgetSession(sessionAffinityKey); + } + } - let accountAuth = accountManager.toAuthDetails(account) as OAuthAuthDetails; - try { - if (shouldRefreshToken(accountAuth, tokenRefreshSkewMs)) { - accountAuth = (await refreshAndUpdateToken( - accountAuth, - client, - )) as OAuthAuthDetails; - accountManager.updateFromAuth(account, accountAuth); - accountManager.clearAuthFailures(account); - accountManager.saveToDiskDebounced(); - } - } catch (err) { - logDebug(`[${PLUGIN_NAME}] Auth refresh failed for account: ${(err as Error)?.message ?? String(err)}`); - runtimeMetrics.authRefreshFailures++; - runtimeMetrics.failedRequests++; - runtimeMetrics.accountRotations++; - runtimeMetrics.lastError = (err as Error)?.message ?? String(err); - const failures = accountManager.incrementAuthFailures(account); - const accountLabel = formatAccountLabel(account, account.index); - - const authFailurePolicy = evaluateFailurePolicy({ - kind: "auth-refresh", - consecutiveAuthFailures: failures, - }); - sessionAffinityStore?.forgetSession(sessionAffinityKey); - - if (authFailurePolicy.removeAccount) { - const removedIndex = account.index; - sessionAffinityStore?.forgetAccount(removedIndex); - accountManager.removeAccount(account); - sessionAffinityStore?.reindexAfterRemoval(removedIndex); - accountManager.saveToDiskDebounced(); - await showToast( - `Removed ${accountLabel} after ${failures} consecutive auth failures. Run 'codex login' to re-add.`, - "error", - { duration: toastDurationMs * 2 }, - ); - continue; - } + if (!account) { + account = accountManager.getCurrentOrNextForFamilyHybrid( + modelFamily, + model, + { + pidOffsetEnabled, + scoreBoostByAccount: capabilityBoostByAccount, + }, + ); + } + if (!account || attempted.has(account.index)) { + break; + } + attempted.add(account.index); + // Log account selection for debugging rotation + logDebug( + `Using account ${account.index + 1}/${accountCount}: ${account.email ?? "unknown"} for ${modelFamily}`, + ); - if ( - typeof authFailurePolicy.cooldownMs === "number" && - authFailurePolicy.cooldownReason - ) { - accountManager.markAccountCoolingDown( - account, - authFailurePolicy.cooldownMs, - authFailurePolicy.cooldownReason, - ); - } - accountManager.saveToDiskDebounced(); - continue; - } + let accountAuth = accountManager.toAuthDetails( + account, + ) as OAuthAuthDetails; + try { + if (shouldRefreshToken(accountAuth, tokenRefreshSkewMs)) { + accountAuth = (await refreshAndUpdateToken( + accountAuth, + client, + )) as OAuthAuthDetails; + accountManager.updateFromAuth(account, accountAuth); + accountManager.clearAuthFailures(account); + accountManager.saveToDiskDebounced(); + } + } catch (err) { + logDebug( + `[${PLUGIN_NAME}] Auth refresh failed for account: ${(err as Error)?.message ?? String(err)}`, + ); + runtimeMetrics.authRefreshFailures++; + runtimeMetrics.failedRequests++; + runtimeMetrics.accountRotations++; + runtimeMetrics.lastError = + (err as Error)?.message ?? String(err); + const failures = + accountManager.incrementAuthFailures(account); + const accountLabel = formatAccountLabel( + account, + account.index, + ); - const currentWorkspace = accountManager.getCurrentWorkspace(account); - const storedAccountId = currentWorkspace?.id ?? account.accountId; - const storedAccountIdSource = currentWorkspace - ? "manual" - : account.accountIdSource; - const storedEmail = account.email; - const hadAccountId = !!storedAccountId; - const runtimeIdentity = resolveRuntimeRequestIdentity({ - storedAccountId, - source: storedAccountIdSource, - storedEmail, - accessToken: accountAuth.access, - idToken: accountAuth.idToken, - }); - const tokenAccountId = runtimeIdentity.tokenAccountId; - const accountId = runtimeIdentity.accountId; - if (!accountId) { - accountManager.markAccountCoolingDown( - account, - ACCOUNT_LIMITS.AUTH_FAILURE_COOLDOWN_MS, - "auth-failure", - ); - accountManager.saveToDiskDebounced(); - continue; - } - const resolvedEmail = runtimeIdentity.email; - const entitlementAccountKey = resolveEntitlementAccountKey({ - accountId: storedAccountId ?? accountId, - email: resolvedEmail, - refreshToken: account.refreshToken, - index: account.index, + const authFailurePolicy = evaluateFailurePolicy({ + kind: "auth-refresh", + consecutiveAuthFailures: failures, }); - const entitlementBlock = entitlementCache.isBlocked( - entitlementAccountKey, - model ?? modelFamily, - ); - if (entitlementBlock.blocked) { - runtimeMetrics.accountRotations++; - runtimeMetrics.lastError = `Entitlement cached block for account ${account.index + 1}`; - logWarn( - `Skipping account ${account.index + 1} due to cached entitlement block (${formatWaitTime(entitlementBlock.waitMs)} remaining).`, + sessionAffinityStore?.forgetSession(sessionAffinityKey); + + if (authFailurePolicy.removeAccount) { + const removedIndex = account.index; + sessionAffinityStore?.forgetAccount(removedIndex); + accountManager.removeAccount(account); + sessionAffinityStore?.reindexAfterRemoval(removedIndex); + accountManager.saveToDiskDebounced(); + await showToast( + `Removed ${accountLabel} after ${failures} consecutive auth failures. Run 'codex login' to re-add.`, + "error", + { duration: toastDurationMs * 2 }, ); continue; } - account.accountId = accountId; - if (!hadAccountId && tokenAccountId && accountId === tokenAccountId) { - account.accountIdSource = storedAccountIdSource ?? "token"; - } - if (resolvedEmail) { - account.email = resolvedEmail; - } if ( - accountCount > 1 && - accountManager.shouldShowAccountToast( - account.index, - rateLimitToastDebounceMs, - ) + typeof authFailurePolicy.cooldownMs === "number" && + authFailurePolicy.cooldownReason ) { - const accountLabel = formatAccountLabel(account, account.index); - await showToast( - `Using ${accountLabel} (${account.index + 1}/${accountCount})`, - "info", + accountManager.markAccountCoolingDown( + account, + authFailurePolicy.cooldownMs, + authFailurePolicy.cooldownReason, ); - accountManager.markToastShown(account.index); } + accountManager.saveToDiskDebounced(); + continue; + } - const headers = createCodexHeaders( - requestInit, - accountId, - accountAuth.access, - { - model, - promptCacheKey: effectivePromptCacheKey, - }, - ); - const quotaScheduleKey = `${entitlementAccountKey}:${model ?? modelFamily}`; - const capabilityModelKey = model ?? modelFamily; - const quotaDeferral = preemptiveQuotaScheduler.getDeferral(quotaScheduleKey); - if (quotaDeferral.defer && quotaDeferral.waitMs > 0) { - accountManager.markRateLimitedWithReason( - account, - quotaDeferral.waitMs, - modelFamily, - "quota", - model, - ); - accountManager.recordRateLimit(account, modelFamily, model); - runtimeMetrics.accountRotations++; - runtimeMetrics.lastError = `Preemptive quota deferral for account ${account.index + 1}`; - accountManager.saveToDiskDebounced(); - continue; - } - - // Consume a token before making the request for proactive rate limiting - const tokenConsumed = accountManager.consumeToken(account, modelFamily, model); - if (!tokenConsumed) { - accountManager.recordRateLimit(account, modelFamily, model); - runtimeMetrics.accountRotations++; - runtimeMetrics.lastError = - `Local token bucket depleted for account ${account.index + 1} (${modelFamily}${model ? `:${model}` : ""})`; - logWarn( - `Skipping account ${account.index + 1}: local token bucket depleted for ${modelFamily}${model ? `:${model}` : ""}`, + const currentWorkspace = + accountManager.getCurrentWorkspace(account); + const storedAccountId = + currentWorkspace?.id ?? account.accountId; + const storedAccountIdSource = currentWorkspace + ? "manual" + : account.accountIdSource; + const storedEmail = account.email; + const hadAccountId = !!storedAccountId; + const runtimeIdentity = resolveRuntimeRequestIdentity({ + storedAccountId, + source: storedAccountIdSource, + storedEmail, + accessToken: accountAuth.access, + idToken: accountAuth.idToken, + }); + const tokenAccountId = runtimeIdentity.tokenAccountId; + const accountId = runtimeIdentity.accountId; + if (!accountId) { + accountManager.markAccountCoolingDown( + account, + ACCOUNT_LIMITS.AUTH_FAILURE_COOLDOWN_MS, + "auth-failure", + ); + accountManager.saveToDiskDebounced(); + continue; + } + const resolvedEmail = runtimeIdentity.email; + const entitlementAccountKey = resolveEntitlementAccountKey({ + accountId: storedAccountId ?? accountId, + email: resolvedEmail, + refreshToken: account.refreshToken, + index: account.index, + }); + const entitlementBlock = entitlementCache.isBlocked( + entitlementAccountKey, + model ?? modelFamily, ); - continue; - } - - let sameAccountRetryCount = 0; - let successAccountForResponse = account; - let successEntitlementAccountKey = entitlementAccountKey; - while (true) { - let response: Response; - const fetchStart = performance.now(); - - // Merge user AbortSignal with timeout (Node 18 compatible - no AbortSignal.any) - const fetchController = new AbortController(); - const requestTimeoutMs = fetchTimeoutMs; - let requestTimedOut = false; - const timeoutReason = new Error("Request timeout"); - const fetchTimeoutId = setTimeout(() => { - requestTimedOut = true; - fetchController.abort(timeoutReason); - }, requestTimeoutMs); - - const onUserAbort = abortSignal - ? () => fetchController.abort(abortSignal.reason ?? new Error("Aborted by user")) - : null; - - if (abortSignal?.aborted) { - clearTimeout(fetchTimeoutId); - fetchController.abort(abortSignal.reason ?? new Error("Aborted by user")); - } else if (abortSignal && onUserAbort) { - abortSignal.addEventListener("abort", onUserAbort, { once: true }); - } + if (entitlementBlock.blocked) { + runtimeMetrics.accountRotations++; + runtimeMetrics.lastError = `Entitlement cached block for account ${account.index + 1}`; + logWarn( + `Skipping account ${account.index + 1} due to cached entitlement block (${formatWaitTime(entitlementBlock.waitMs)} remaining).`, + ); + continue; + } + account.accountId = accountId; + if ( + !hadAccountId && + tokenAccountId && + accountId === tokenAccountId + ) { + account.accountIdSource = + storedAccountIdSource ?? "token"; + } + if (resolvedEmail) { + account.email = resolvedEmail; + } - try { - runtimeMetrics.totalRequests++; - response = await fetch(url, applyProxyCompatibleInit(url, { - ...requestInit, - headers, - signal: fetchController.signal, - })); - } catch (networkError) { - const fetchAbortReason = fetchController.signal.reason; - const isTimeoutAbort = - requestTimedOut || - (fetchAbortReason instanceof Error && - fetchAbortReason.message === timeoutReason.message); - const isUserAbort = Boolean(abortSignal?.aborted) && !isTimeoutAbort; - if (isUserAbort) { - accountManager.refundToken(account, modelFamily, model); - runtimeMetrics.userAborts++; - runtimeMetrics.lastError = "request aborted by user"; - sessionAffinityStore?.forgetSession(sessionAffinityKey); - throw ( - fetchAbortReason instanceof Error - ? fetchAbortReason - : new Error("Aborted by user") - ); - } - const errorMsg = networkError instanceof Error ? networkError.message : String(networkError); - logWarn(`Network error for account ${account.index + 1}: ${errorMsg}`); - runtimeMetrics.failedRequests++; - runtimeMetrics.networkErrors++; - runtimeMetrics.accountRotations++; - runtimeMetrics.lastError = errorMsg; - const policy = evaluateFailurePolicy( - { kind: "network", failoverMode }, - { networkCooldownMs: networkErrorCooldownMs }, - ); - if (policy.refundToken) { - accountManager.refundToken(account, modelFamily, model); - } - if (policy.recordFailure) { - accountManager.recordFailure(account, modelFamily, model); - capabilityPolicyStore.recordFailure( - entitlementAccountKey, - capabilityModelKey, - ); - } - if ( - policy.retrySameAccount && - sameAccountRetryCount < maxSameAccountRetries - ) { - sameAccountRetryCount += 1; - runtimeMetrics.sameAccountRetries += 1; - const retryDelayMs = Math.max( - MIN_BACKOFF_MS, - Math.floor(policy.retryDelayMs ?? 250), - ); - await sleep(addJitter(retryDelayMs, 0.2)); - continue; - } - if ( - typeof policy.cooldownMs === "number" && - policy.cooldownReason - ) { - accountManager.markAccountCoolingDown( - account, - policy.cooldownMs, - policy.cooldownReason, - ); - accountManager.saveToDiskDebounced(); - } - sessionAffinityStore?.forgetSession(sessionAffinityKey); - break; - } finally { - clearTimeout(fetchTimeoutId); - if (abortSignal && onUserAbort) { - abortSignal.removeEventListener("abort", onUserAbort); - } - } - const fetchLatencyMs = Math.round(performance.now() - fetchStart); - - logRequest(LOG_STAGES.RESPONSE, { - status: response.status, - ok: response.ok, - statusText: response.statusText, - latencyMs: fetchLatencyMs, - headers: sanitizeResponseHeadersForLog(response.headers), - }); - const quotaSnapshot = readQuotaSchedulerSnapshot( - response.headers, - response.status, - ); - if (quotaSnapshot) { - preemptiveQuotaScheduler.update(quotaScheduleKey, quotaSnapshot); - } - - if (!response.ok) { - const contextOverflowResult = await handleContextOverflow(response, model); - if (contextOverflowResult.handled) { - return contextOverflowResult.response; - } - - const { response: errorResponse, rateLimit, errorBody } = - await handleErrorResponse(response, { - requestCorrelationId, - threadId: threadIdCandidate, - }); - - const unsupportedModelInfo = getUnsupportedCodexModelInfo(errorBody); - const hasRemainingAccounts = attempted.size < Math.max(1, accountCount); - const blockedModel = - unsupportedModelInfo.unsupportedModel ?? model ?? "requested model"; - const blockedModelNormalized = blockedModel.toLowerCase(); - const shouldForceSparkFallback = - unsupportedModelInfo.isUnsupported && - (blockedModelNormalized === "gpt-5.3-codex-spark" || - blockedModelNormalized.includes("gpt-5.3-codex-spark")); - const allowUnsupportedFallback = - fallbackOnUnsupportedCodexModel || shouldForceSparkFallback; - - // Entitlements can differ by account/workspace, so try remaining - // accounts before degrading the model via fallback. - // Spark entitlement is commonly unavailable on non-Pro/Business workspaces; - // force direct fallback instead of traversing every account/workspace first. - if ( - unsupportedModelInfo.isUnsupported && - hasRemainingAccounts && - !shouldForceSparkFallback - ) { - entitlementCache.markBlocked( - entitlementAccountKey, - blockedModel, - "unsupported-model", - ); - capabilityPolicyStore.recordUnsupported( - entitlementAccountKey, - blockedModel, - ); - accountManager.refundToken(account, modelFamily, model); - accountManager.recordFailure(account, modelFamily, model); - capabilityPolicyStore.recordFailure( - entitlementAccountKey, - capabilityModelKey, - ); - sessionAffinityStore?.forgetSession(sessionAffinityKey); - account.lastSwitchReason = "rotation"; - runtimeMetrics.lastError = `Unsupported model on account ${account.index + 1}: ${blockedModel}`; - logWarn( - `Model ${blockedModel} is unsupported for account ${account.index + 1}. Trying next account/workspace before fallback.`, - { - unsupportedCodexPolicy, - requestedModel: blockedModel, - effectiveModel: blockedModel, - fallbackApplied: false, - fallbackReason: "unsupported-model-entitlement", - }, - ); - retryNextAccountBeforeFallback = true; - break; - } - - const fallbackModel = resolveUnsupportedCodexFallbackModel({ - requestedModel: model, - errorBody, - attemptedModels: attemptedUnsupportedFallbackModels, - fallbackOnUnsupportedCodexModel: allowUnsupportedFallback, - fallbackToGpt52OnUnsupportedGpt53, - customChain: unsupportedCodexFallbackChain, - }); - - if (fallbackModel) { - const previousModel = model ?? "gpt-5-codex"; - const previousModelFamily = modelFamily; - attemptedUnsupportedFallbackModels.add(previousModel); - attemptedUnsupportedFallbackModels.add(fallbackModel); - entitlementCache.markBlocked( - entitlementAccountKey, - previousModel, - "unsupported-model", - ); - capabilityPolicyStore.recordUnsupported( - entitlementAccountKey, - previousModel, - ); - accountManager.refundToken(account, previousModelFamily, previousModel); - - model = fallbackModel; - modelFamily = getModelFamily(model); - quotaKey = `${modelFamily}:${model}`; - - if (transformedBody && typeof transformedBody === "object") { - transformedBody = { ...transformedBody, model }; - } else { - let fallbackBody: Record = { model }; - if (requestInit?.body && typeof requestInit.body === "string") { - try { - const parsed = JSON.parse(requestInit.body) as Record; - fallbackBody = { ...parsed, model }; - } catch { - // Keep minimal fallback body if parsing fails. - } - } - transformedBody = fallbackBody as RequestBody; - } - - requestInit = { - ...(requestInit ?? {}), - body: JSON.stringify(transformedBody), - }; - runtimeMetrics.lastError = `Model fallback: ${previousModel} -> ${model}`; - logWarn( - `Model ${previousModel} is unsupported for this ChatGPT account. Falling back to ${model}.`, - { - unsupportedCodexPolicy, - requestedModel: previousModel, - effectiveModel: model, - fallbackApplied: true, - fallbackReason: "unsupported-model-entitlement", - }, - ); - await showToast( - `Model ${previousModel} is not available for this account. Retrying with ${model}.`, - "warning", - { duration: toastDurationMs }, - ); - restartAccountTraversalWithFallback = true; - break; - } - - if (unsupportedModelInfo.isUnsupported && !allowUnsupportedFallback) { - entitlementCache.markBlocked( - entitlementAccountKey, - blockedModel, - "unsupported-model", - ); - capabilityPolicyStore.recordUnsupported( - entitlementAccountKey, - blockedModel, - ); - runtimeMetrics.lastError = `Unsupported model (strict): ${blockedModel}`; - logWarn( - `Model ${blockedModel} is unsupported for this ChatGPT account. Strict policy blocks automatic fallback.`, - { - unsupportedCodexPolicy, - requestedModel: blockedModel, - effectiveModel: blockedModel, - fallbackApplied: false, - fallbackReason: "unsupported-model-entitlement", - }, - ); - await showToast( - `Model ${blockedModel} is not available for this account. Strict policy blocked automatic fallback.`, - "warning", - { duration: toastDurationMs }, - ); - } - if ( - unsupportedModelInfo.isUnsupported && - allowUnsupportedFallback && - !hasRemainingAccounts && - !fallbackModel - ) { - entitlementCache.markBlocked( - entitlementAccountKey, - blockedModel, - "unsupported-model", - ); - capabilityPolicyStore.recordUnsupported( - entitlementAccountKey, - blockedModel, - ); - } - const workspaceErrorCode = - (errorBody as { error?: { code?: string } } | undefined)?.error?.code ?? ""; - const workspaceErrorMessage = - (errorBody as { error?: { message?: string } } | undefined)?.error?.message ?? ""; - const isDisabledWorkspaceError = - isWorkspaceDisabledError( - errorResponse.status, - workspaceErrorCode, - workspaceErrorMessage, - ); - - // Handle workspace disabled/expired errors by rotating to the next workspace - // within the same account before falling back to another account. - if (isDisabledWorkspaceError) { - runtimeMetrics.failedRequests++; - runtimeMetrics.lastError = `Workspace disabled for account ${account.index + 1}`; - - if (!account.workspaces || account.workspaces.length === 0) { - logWarn( - `Workspace disabled/expired for account ${account.index + 1} without tracked workspaces. Leaving account enabled.`, - { errorCode: workspaceErrorCode }, - ); - if (hasRemainingAccounts) { - continue accountAttemptLoop; - } - return errorResponse; - } else { - const currentWorkspace = accountManager.getCurrentWorkspace(account); - const workspaceName = currentWorkspace?.name ?? currentWorkspace?.id ?? "unknown"; - - logWarn( - `Workspace disabled/expired for account ${account.index + 1} - workspace: ${workspaceName}. Rotating to next workspace.`, - { errorCode: workspaceErrorCode }, - ); - - const disabledWorkspace = currentWorkspace - ? accountManager.disableCurrentWorkspace(account, currentWorkspace.id) - : false; - let nextWorkspace = disabledWorkspace - ? accountManager.rotateToNextWorkspace(account) - : accountManager.getCurrentWorkspace(account); - if (!disabledWorkspace && (!nextWorkspace || nextWorkspace.enabled === false)) { - nextWorkspace = accountManager.rotateToNextWorkspace(account); - } - - if (nextWorkspace) { - accountManager.saveToDiskDebounced(); - - const newWorkspaceName = nextWorkspace.name ?? nextWorkspace.id; - await showToast( - `Workspace ${workspaceName} disabled. Switched to ${newWorkspaceName}.`, - "warning", - { duration: toastDurationMs }, - ); - - logInfo(`Rotated to workspace ${newWorkspaceName} for account ${account.index + 1}`); - - // Allow the same account to be selected again with fresh request state. - attempted.delete(account.index); - continue accountAttemptLoop; - } - - logWarn(`All workspaces disabled for account ${account.index + 1}. Disabling account.`); - - accountManager.setAccountEnabled(account.index, false); - accountManager.saveToDiskDebounced(); - - await showToast( - `All workspaces disabled for account ${account.index + 1}. Switching to another account.`, - "warning", - { duration: toastDurationMs }, - ); - - // Forget session affinity and continue the outer loop so another - // enabled account can service the request. - sessionAffinityStore?.forgetSession(sessionAffinityKey); - continue accountAttemptLoop; - } - } - - if (errorResponse.status === 403 && !unsupportedModelInfo.isUnsupported && !isDisabledWorkspaceError) { - entitlementCache.markBlocked( - entitlementAccountKey, - model ?? modelFamily, - "plan-entitlement", - ); - capabilityPolicyStore.recordFailure( - entitlementAccountKey, - capabilityModelKey, - ); - } - - if (recoveryHook && errorBody && isRecoverableError(errorBody)) { - const errorType = detectErrorType(errorBody); - const toastContent = getRecoveryToastContent(errorType); - await showToast( - `${toastContent.title}: ${toastContent.message}`, - "warning", - { duration: toastDurationMs }, - ); - logDebug(`[${PLUGIN_NAME}] Recoverable error detected: ${errorType}`); - } - - // Handle 5xx server errors by rotating to another account - if (response.status >= 500 && response.status < 600) { - logWarn(`Server error ${response.status} for account ${account.index + 1}. Rotating to next account.`); - runtimeMetrics.failedRequests++; - runtimeMetrics.serverErrors++; - runtimeMetrics.accountRotations++; - runtimeMetrics.lastError = `HTTP ${response.status}`; - const serverRetryAfterMs = parseRetryAfterHintMs(response.headers); - const policy = evaluateFailurePolicy( - { kind: "server", failoverMode, serverRetryAfterMs: serverRetryAfterMs ?? undefined }, - { serverCooldownMs: serverErrorCooldownMs }, - ); - if (policy.refundToken) { - accountManager.refundToken(account, modelFamily, model); - } - if (policy.recordFailure) { - accountManager.recordFailure(account, modelFamily, model); - capabilityPolicyStore.recordFailure( - entitlementAccountKey, - capabilityModelKey, - ); - } - if ( - policy.retrySameAccount && - sameAccountRetryCount < maxSameAccountRetries - ) { - sameAccountRetryCount += 1; - runtimeMetrics.sameAccountRetries += 1; - const retryDelayMs = Math.max( - MIN_BACKOFF_MS, - Math.floor(policy.retryDelayMs ?? 500), - ); - await sleep(addJitter(retryDelayMs, 0.2)); - continue; - } - if ( - typeof policy.cooldownMs === "number" && - policy.cooldownReason - ) { - accountManager.markAccountCoolingDown( - account, - policy.cooldownMs, - policy.cooldownReason, - ); - accountManager.saveToDiskDebounced(); - } - sessionAffinityStore?.forgetSession(sessionAffinityKey); - break; - } - - if (rateLimit) { - runtimeMetrics.rateLimitedResponses++; - const { attempt, delayMs } = getRateLimitBackoff( - account.index, - quotaKey, - rateLimit.retryAfterMs, - ); - preemptiveQuotaScheduler.markRateLimited( - quotaScheduleKey, - delayMs, - ); - const waitLabel = formatWaitTime(delayMs); - - if (delayMs <= RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS) { - if ( - accountManager.shouldShowAccountToast( - account.index, - rateLimitToastDebounceMs, - ) - ) { - await showToast( - `Rate limited. Retrying in ${waitLabel} (attempt ${attempt})...`, - "warning", - { duration: toastDurationMs }, - ); - accountManager.markToastShown(account.index); - } - - await sleep(addJitter(Math.max(MIN_BACKOFF_MS, delayMs), 0.2)); - continue; - } - - accountManager.markRateLimitedWithReason( - account, - delayMs, - modelFamily, - parseRateLimitReason(rateLimit.code), - model, - ); - accountManager.recordRateLimit(account, modelFamily, model); - account.lastSwitchReason = "rate-limit"; - sessionAffinityStore?.forgetSession(sessionAffinityKey); - runtimeMetrics.accountRotations++; - accountManager.saveToDiskDebounced(); - logWarn( - `Rate limited. Rotating account ${account.index + 1} (${account.email ?? "unknown"}).`, - ); - - if ( - accountManager.getAccountCount() > 1 && - accountManager.shouldShowAccountToast( - account.index, - rateLimitToastDebounceMs, - ) - ) { - await showToast( - `Rate limited. Switching accounts (retry in ${waitLabel}).`, - "warning", - { duration: toastDurationMs }, - ); - accountManager.markToastShown(account.index); - } - break; - } - if ( - !rateLimit && - !unsupportedModelInfo.isUnsupported && - errorResponse.status !== 403 - ) { - capabilityPolicyStore.recordFailure( - entitlementAccountKey, - capabilityModelKey, - ); - } - runtimeMetrics.failedRequests++; - runtimeMetrics.lastError = `HTTP ${response.status}`; - return errorResponse; - } - - resetRateLimitBackoff(account.index, quotaKey); - runtimeMetrics.cumulativeLatencyMs += fetchLatencyMs; - let responseForSuccess = response; - if (isStreaming) { - const streamFallbackCandidateOrder = [ - account.index, - ...accountManager - .getAccountsSnapshot() - .map((candidate) => candidate.index) - .filter((index) => index !== account.index), - ]; - responseForSuccess = withStreamingFailover( - response, - async (failoverAttempt, emittedBytes) => { - if (abortSignal?.aborted) { - return null; - } - runtimeMetrics.streamFailoverAttempts += 1; - - for (const candidateIndex of streamFallbackCandidateOrder) { - if (abortSignal?.aborted) { - return null; - } if ( - !accountManager.isAccountAvailableForFamily( - candidateIndex, - modelFamily, - model, + accountCount > 1 && + accountManager.shouldShowAccountToast( + account.index, + rateLimitToastDebounceMs, ) ) { - continue; - } - - const fallbackAccount = accountManager.getAccountByIndex(candidateIndex); - if (!fallbackAccount) continue; - - let fallbackAuth = accountManager.toAuthDetails(fallbackAccount) as OAuthAuthDetails; - try { - if (shouldRefreshToken(fallbackAuth, tokenRefreshSkewMs)) { - fallbackAuth = (await refreshAndUpdateToken( - fallbackAuth, - client, - )) as OAuthAuthDetails; - accountManager.updateFromAuth(fallbackAccount, fallbackAuth); - accountManager.clearAuthFailures(fallbackAccount); - accountManager.saveToDiskDebounced(); - } - } catch (refreshError) { - logWarn( - `Stream failover refresh failed for account ${fallbackAccount.index + 1}.`, - { - error: - refreshError instanceof Error - ? refreshError.message - : String(refreshError), - }, + const accountLabel = formatAccountLabel( + account, + account.index, ); - continue; - } - - const fallbackStoredAccountId = fallbackAccount.accountId; - const fallbackStoredAccountIdSource = fallbackAccount.accountIdSource; - const fallbackStoredEmail = fallbackAccount.email; - const hadFallbackAccountId = !!fallbackStoredAccountId; - const fallbackRuntimeIdentity = resolveRuntimeRequestIdentity({ - storedAccountId: fallbackStoredAccountId, - source: fallbackStoredAccountIdSource, - storedEmail: fallbackStoredEmail, - accessToken: fallbackAuth.access, - idToken: fallbackAuth.idToken, - }); - const fallbackTokenAccountId = fallbackRuntimeIdentity.tokenAccountId; - const fallbackAccountId = fallbackRuntimeIdentity.accountId; - if (!fallbackAccountId) { - continue; - } - const fallbackResolvedEmail = fallbackRuntimeIdentity.email; - const fallbackEntitlementAccountKey = resolveEntitlementAccountKey({ - accountId: fallbackStoredAccountId ?? fallbackAccountId, - email: fallbackResolvedEmail, - refreshToken: fallbackAccount.refreshToken, - index: fallbackAccount.index, - }); - const fallbackEntitlementBlock = entitlementCache.isBlocked( - fallbackEntitlementAccountKey, - model ?? modelFamily, - ); - if (fallbackEntitlementBlock.blocked) { - runtimeMetrics.accountRotations++; - runtimeMetrics.lastError = - `Entitlement cached block for account ${fallbackAccount.index + 1}`; - logWarn( - `Skipping account ${fallbackAccount.index + 1} due to cached entitlement block (${formatWaitTime(fallbackEntitlementBlock.waitMs)} remaining).`, + await showToast( + `Using ${accountLabel} (${account.index + 1}/${accountCount})`, + "info", ); - continue; - } - - if (!accountManager.consumeToken(fallbackAccount, modelFamily, model)) { - continue; - } - fallbackAccount.accountId = fallbackAccountId; - if ( - !hadFallbackAccountId && - fallbackTokenAccountId && - fallbackAccountId === fallbackTokenAccountId - ) { - fallbackAccount.accountIdSource = - fallbackStoredAccountIdSource ?? "token"; - } - if (fallbackResolvedEmail) { - fallbackAccount.email = fallbackResolvedEmail; + accountManager.markToastShown(account.index); } - const fallbackHeaders = createCodexHeaders( + const headers = createCodexHeaders( requestInit, - fallbackAccountId, - fallbackAuth.access, + accountId, + accountAuth.access, { model, promptCacheKey: effectivePromptCacheKey, }, ); + const quotaScheduleKey = `${entitlementAccountKey}:${model ?? modelFamily}`; + const capabilityModelKey = model ?? modelFamily; + const quotaDeferral = + preemptiveQuotaScheduler.getDeferral(quotaScheduleKey); + if (quotaDeferral.defer && quotaDeferral.waitMs > 0) { + accountManager.markRateLimitedWithReason( + account, + quotaDeferral.waitMs, + modelFamily, + "quota", + model, + ); + accountManager.recordRateLimit( + account, + modelFamily, + model, + ); + runtimeMetrics.accountRotations++; + runtimeMetrics.lastError = `Preemptive quota deferral for account ${account.index + 1}`; + accountManager.saveToDiskDebounced(); + continue; + } - const fallbackController = new AbortController(); - const fallbackTimeoutId = setTimeout( - () => fallbackController.abort(new Error("Request timeout")), - fetchTimeoutMs, + // Consume a token before making the request for proactive rate limiting + const tokenConsumed = accountManager.consumeToken( + account, + modelFamily, + model, ); - const onFallbackAbort = abortSignal - ? () => - fallbackController.abort( - abortSignal.reason ?? new Error("Aborted by user"), - ) - : null; - if (abortSignal && onFallbackAbort) { - abortSignal.addEventListener("abort", onFallbackAbort, { - once: true, - }); + if (!tokenConsumed) { + accountManager.recordRateLimit( + account, + modelFamily, + model, + ); + runtimeMetrics.accountRotations++; + runtimeMetrics.lastError = `Local token bucket depleted for account ${account.index + 1} (${modelFamily}${model ? `:${model}` : ""})`; + logWarn( + `Skipping account ${account.index + 1}: local token bucket depleted for ${modelFamily}${model ? `:${model}` : ""}`, + ); + continue; } - try { - runtimeMetrics.totalRequests++; - const fallbackResponse = await fetch(url, applyProxyCompatibleInit(url, { - ...requestInit, - headers: fallbackHeaders, - signal: fallbackController.signal, - })); - const fallbackSnapshot = readQuotaSchedulerSnapshot( - fallbackResponse.headers, - fallbackResponse.status, - ); - if (fallbackSnapshot) { - preemptiveQuotaScheduler.update( - `${fallbackEntitlementAccountKey}:${model ?? modelFamily}`, - fallbackSnapshot, + let sameAccountRetryCount = 0; + let successAccountForResponse = account; + let successEntitlementAccountKey = entitlementAccountKey; + while (true) { + let response: Response; + const fetchStart = performance.now(); + + // Merge user AbortSignal with timeout (Node 18 compatible - no AbortSignal.any) + const fetchController = new AbortController(); + const requestTimeoutMs = fetchTimeoutMs; + let requestTimedOut = false; + const timeoutReason = new Error("Request timeout"); + const fetchTimeoutId = setTimeout(() => { + requestTimedOut = true; + fetchController.abort(timeoutReason); + }, requestTimeoutMs); + + const onUserAbort = abortSignal + ? () => + fetchController.abort( + abortSignal.reason ?? + new Error("Aborted by user"), + ) + : null; + + if (abortSignal?.aborted) { + clearTimeout(fetchTimeoutId); + fetchController.abort( + abortSignal.reason ?? new Error("Aborted by user"), ); + } else if (abortSignal && onUserAbort) { + abortSignal.addEventListener("abort", onUserAbort, { + once: true, + }); } - if (!fallbackResponse.ok) { - try { - await fallbackResponse.body?.cancel(); - } catch { - // Best effort cleanup before trying next fallback account. - } - if (fallbackResponse.status === 429) { - const retryAfterMs = - parseRetryAfterHintMs(fallbackResponse.headers) ?? 60_000; - accountManager.markRateLimitedWithReason( - fallbackAccount, - retryAfterMs, + + try { + runtimeMetrics.totalRequests++; + response = await fetch( + url, + applyProxyCompatibleInit(url, { + ...requestInit, + headers, + signal: fetchController.signal, + }), + ); + } catch (networkError) { + const fetchAbortReason = fetchController.signal.reason; + const isTimeoutAbort = + requestTimedOut || + (fetchAbortReason instanceof Error && + fetchAbortReason.message === timeoutReason.message); + const isUserAbort = + Boolean(abortSignal?.aborted) && !isTimeoutAbort; + if (isUserAbort) { + accountManager.refundToken( + account, modelFamily, - "quota", model, ); - accountManager.recordRateLimit(fallbackAccount, modelFamily, model); - } else { - accountManager.recordFailure(fallbackAccount, modelFamily, model); + runtimeMetrics.userAborts++; + runtimeMetrics.lastError = "request aborted by user"; + sessionAffinityStore?.forgetSession( + sessionAffinityKey, + ); + throw fetchAbortReason instanceof Error + ? fetchAbortReason + : new Error("Aborted by user"); } - capabilityPolicyStore.recordFailure( - fallbackEntitlementAccountKey, - capabilityModelKey, + const errorMsg = + networkError instanceof Error + ? networkError.message + : String(networkError); + logWarn( + `Network error for account ${account.index + 1}: ${errorMsg}`, ); - continue; - } - - successAccountForResponse = fallbackAccount; - successEntitlementAccountKey = fallbackEntitlementAccountKey; - runtimeMetrics.streamFailoverRecoveries += 1; - if (fallbackAccount.index !== account.index) { - runtimeMetrics.streamFailoverCrossAccountRecoveries += 1; - runtimeMetrics.accountRotations += 1; - sessionAffinityStore?.remember( - sessionAffinityKey, - fallbackAccount.index, + runtimeMetrics.failedRequests++; + runtimeMetrics.networkErrors++; + runtimeMetrics.accountRotations++; + runtimeMetrics.lastError = errorMsg; + const policy = evaluateFailurePolicy( + { kind: "network", failoverMode }, + { networkCooldownMs: networkErrorCooldownMs }, ); + if (policy.refundToken) { + accountManager.refundToken( + account, + modelFamily, + model, + ); + } + if (policy.recordFailure) { + accountManager.recordFailure( + account, + modelFamily, + model, + ); + capabilityPolicyStore.recordFailure( + entitlementAccountKey, + capabilityModelKey, + ); + } + if ( + policy.retrySameAccount && + sameAccountRetryCount < maxSameAccountRetries + ) { + sameAccountRetryCount += 1; + runtimeMetrics.sameAccountRetries += 1; + const retryDelayMs = Math.max( + MIN_BACKOFF_MS, + Math.floor(policy.retryDelayMs ?? 250), + ); + await sleep(addJitter(retryDelayMs, 0.2)); + continue; + } + if ( + typeof policy.cooldownMs === "number" && + policy.cooldownReason + ) { + accountManager.markAccountCoolingDown( + account, + policy.cooldownMs, + policy.cooldownReason, + ); + accountManager.saveToDiskDebounced(); + } + sessionAffinityStore?.forgetSession(sessionAffinityKey); + break; + } finally { + clearTimeout(fetchTimeoutId); + if (abortSignal && onUserAbort) { + abortSignal.removeEventListener("abort", onUserAbort); + } } - - logInfo( - `Recovered stream via failover attempt ${failoverAttempt} using account ${fallbackAccount.index + 1}.`, - { emittedBytes }, - ); - return fallbackResponse; - } catch (streamFailoverError) { - accountManager.refundToken(fallbackAccount, modelFamily, model); - accountManager.recordFailure(fallbackAccount, modelFamily, model); - capabilityPolicyStore.recordFailure( - fallbackEntitlementAccountKey, - capabilityModelKey, + const fetchLatencyMs = Math.round( + performance.now() - fetchStart, ); - logWarn( - `Stream failover attempt ${failoverAttempt} failed for account ${fallbackAccount.index + 1}.`, - { - emittedBytes, - error: - streamFailoverError instanceof Error - ? streamFailoverError.message - : String(streamFailoverError), - }, + + logRequest(LOG_STAGES.RESPONSE, { + status: response.status, + ok: response.ok, + statusText: response.statusText, + latencyMs: fetchLatencyMs, + headers: sanitizeResponseHeadersForLog( + response.headers, + ), + }); + const quotaSnapshot = readQuotaSchedulerSnapshot( + response.headers, + response.status, ); - continue; - } finally { - clearTimeout(fallbackTimeoutId); - if (abortSignal && onFallbackAbort) { - abortSignal.removeEventListener("abort", onFallbackAbort); + if (quotaSnapshot) { + preemptiveQuotaScheduler.update( + quotaScheduleKey, + quotaSnapshot, + ); } - } - } - - return null; - }, - { - maxFailovers: streamFailoverMax, - softTimeoutMs: streamFailoverSoftTimeoutMs, - hardTimeoutMs: streamFailoverHardTimeoutMs, - requestInstanceId: requestCorrelationId ?? undefined, - }, - ); - } - const successResponse = await handleSuccessResponse(responseForSuccess, isStreaming, { - streamStallTimeoutMs, - }); - - if (!isStreaming && emptyResponseMaxRetries > 0) { - const clonedResponse = successResponse.clone(); - try { - const bodyText = await clonedResponse.text(); - const parsedBody = bodyText ? JSON.parse(bodyText) as unknown : null; - if (isEmptyResponse(parsedBody)) { - if (emptyResponseRetries < emptyResponseMaxRetries) { - emptyResponseRetries++; - runtimeMetrics.emptyResponseRetries++; - logWarn(`Empty response received (attempt ${emptyResponseRetries}/${emptyResponseMaxRetries}). Retrying...`); - await showToast( - `Empty response. Retrying (${emptyResponseRetries}/${emptyResponseMaxRetries})...`, - "warning", - { duration: toastDurationMs }, - ); - accountManager.refundToken(account, modelFamily, model); - accountManager.recordFailure(account, modelFamily, model); - capabilityPolicyStore.recordFailure( - entitlementAccountKey, - capabilityModelKey, - ); - const emptyPolicy = evaluateFailurePolicy({ - kind: "empty-response", - failoverMode, - }); - if ( - emptyPolicy.retrySameAccount && - sameAccountRetryCount < maxSameAccountRetries - ) { - sameAccountRetryCount += 1; - runtimeMetrics.sameAccountRetries += 1; - const retryDelayMs = Math.max( - 0, - Math.floor(emptyPolicy.retryDelayMs ?? emptyResponseRetryDelayMs), - ); - if (retryDelayMs > 0) { - await sleep(addJitter(retryDelayMs, 0.2)); - } - continue; - } - sessionAffinityStore?.forgetSession(sessionAffinityKey); - await sleep(addJitter(emptyResponseRetryDelayMs, 0.2)); - break; - } - logWarn(`Empty response after ${emptyResponseMaxRetries} retries. Returning as-is.`); - } - } catch { - // Intentionally empty: non-JSON response bodies should be returned as-is - } - } - - if (successAccountForResponse.index !== account.index) { - accountManager.markSwitched(successAccountForResponse, "rotation", modelFamily); - } - const successAccountKey = successEntitlementAccountKey; - accountManager.recordSuccess(successAccountForResponse, modelFamily, model); - capabilityPolicyStore.recordSuccess( - successAccountKey, - capabilityModelKey, - ); - entitlementCache.clear(successAccountKey, capabilityModelKey); - sessionAffinityStore?.remember( - sessionAffinityKey, - successAccountForResponse.index, - ); - runtimeMetrics.successfulRequests++; - runtimeMetrics.lastError = null; - if (lastCodexCliActiveSyncIndex !== successAccountForResponse.index) { - void accountManager.syncCodexCliActiveSelectionForIndex(successAccountForResponse.index); - lastCodexCliActiveSyncIndex = successAccountForResponse.index; - } - return successResponse; - } - if (retryNextAccountBeforeFallback) { - retryNextAccountBeforeFallback = false; - continue; - } - - if (restartAccountTraversalWithFallback) { - break; - } - } - if (restartAccountTraversalWithFallback) { - continue; - } - - const waitMs = accountManager.getMinWaitTimeForFamily(modelFamily, model); - const count = accountManager.getAccountCount(); - - if ( - retryAllAccountsRateLimited && - count > 0 && - waitMs > 0 && - (retryAllAccountsMaxWaitMs === 0 || - waitMs <= retryAllAccountsMaxWaitMs) && - allRateLimitedRetries < retryAllAccountsMaxRetries - ) { - const countdownMessage = `All ${count} account(s) rate-limited. Waiting`; - await sleepWithCountdown(addJitter(waitMs, 0.2), countdownMessage); - allRateLimitedRetries++; - continue; - } - - const waitLabel = waitMs > 0 ? formatWaitTime(waitMs) : "a bit"; - const message = - count === 0 - ? "No Codex accounts configured. Run `codex login`." - : waitMs > 0 - ? `All ${count} account(s) are rate-limited. Try again in ${waitLabel} or add another account with \`codex login\`.` - : `All ${count} account(s) failed (server errors or auth issues). Check account health with \`codex-health\`.`; - runtimeMetrics.failedRequests++; - runtimeMetrics.lastError = message; - return new Response(JSON.stringify({ error: { message } }), { - status: waitMs > 0 ? 429 : 503, - headers: { - "content-type": "application/json; charset=utf-8", - }, - }); - } - } finally { - clearCorrelationId(); - } - }, - }; - } finally { - resolveMutex?.(); - loaderMutex = null; - } - }, - methods: [ - { - label: AUTH_LABELS.OAUTH, - type: "oauth" as const, - authorize: async (inputs?: Record) => { - const authPluginConfig = loadPluginConfig(); - applyUiRuntimeFromConfig(authPluginConfig); - applyAccountStorageScope(authPluginConfig); - - const accounts: TokenSuccessWithAccount[] = []; - const noBrowser = - inputs?.manual === "true" || - inputs?.noBrowser === "true" || - inputs?.["no-browser"] === "true"; - const useManualMode = noBrowser || isBrowserLaunchSuppressed(); - const explicitLoginMode = - inputs?.loginMode === "fresh" || inputs?.loginMode === "add" - ? inputs.loginMode - : null; - - let startFresh = explicitLoginMode === "fresh"; - let refreshAccountIndex: number | undefined; - - const clampActiveIndices = (storage: AccountStorageV3): void => { - const count = storage.accounts.length; - if (count === 0) { - storage.activeIndex = 0; - storage.activeIndexByFamily = {}; - return; - } - storage.activeIndex = Math.max(0, Math.min(storage.activeIndex, count - 1)); - storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; - for (const family of MODEL_FAMILIES) { - const raw = storage.activeIndexByFamily[family]; - const candidate = - typeof raw === "number" && Number.isFinite(raw) ? raw : storage.activeIndex; - storage.activeIndexByFamily[family] = Math.max(0, Math.min(candidate, count - 1)); - } - }; - - const isFlaggableFailure = (failure: Extract): boolean => { - if (failure.reason === "missing_refresh") return true; - if (failure.statusCode === 401) return true; - if (failure.statusCode !== 400) return false; - const message = (failure.message ?? "").toLowerCase(); - return ( - message.includes("invalid_grant") || - message.includes("invalid refresh") || - message.includes("token has been revoked") - ); - }; - - type CodexQuotaWindow = { - usedPercent?: number; - windowMinutes?: number; - resetAtMs?: number; - }; + if (!response.ok) { + const contextOverflowResult = + await handleContextOverflow(response, model); + if (contextOverflowResult.handled) { + return contextOverflowResult.response; + } - type CodexQuotaSnapshot = { - status: number; - planType?: string; - activeLimit?: number; - primary: CodexQuotaWindow; - secondary: CodexQuotaWindow; - }; + const { + response: errorResponse, + rateLimit, + errorBody, + } = await handleErrorResponse(response, { + requestCorrelationId, + threadId: threadIdCandidate, + }); - const parseFiniteNumberHeader = (headers: Headers, name: string): number | undefined => { - const raw = headers.get(name); - if (!raw) return undefined; - const parsed = Number(raw); - return Number.isFinite(parsed) ? parsed : undefined; - }; + const unsupportedModelInfo = + getUnsupportedCodexModelInfo(errorBody); + const hasRemainingAccounts = + attempted.size < Math.max(1, accountCount); + const blockedModel = + unsupportedModelInfo.unsupportedModel ?? + model ?? + "requested model"; + const blockedModelNormalized = + blockedModel.toLowerCase(); + const shouldForceSparkFallback = + unsupportedModelInfo.isUnsupported && + (blockedModelNormalized === "gpt-5.3-codex-spark" || + blockedModelNormalized.includes( + "gpt-5.3-codex-spark", + )); + const allowUnsupportedFallback = + fallbackOnUnsupportedCodexModel || + shouldForceSparkFallback; + + // Entitlements can differ by account/workspace, so try remaining + // accounts before degrading the model via fallback. + // Spark entitlement is commonly unavailable on non-Pro/Business workspaces; + // force direct fallback instead of traversing every account/workspace first. + if ( + unsupportedModelInfo.isUnsupported && + hasRemainingAccounts && + !shouldForceSparkFallback + ) { + entitlementCache.markBlocked( + entitlementAccountKey, + blockedModel, + "unsupported-model", + ); + capabilityPolicyStore.recordUnsupported( + entitlementAccountKey, + blockedModel, + ); + accountManager.refundToken( + account, + modelFamily, + model, + ); + accountManager.recordFailure( + account, + modelFamily, + model, + ); + capabilityPolicyStore.recordFailure( + entitlementAccountKey, + capabilityModelKey, + ); + sessionAffinityStore?.forgetSession( + sessionAffinityKey, + ); + account.lastSwitchReason = "rotation"; + runtimeMetrics.lastError = `Unsupported model on account ${account.index + 1}: ${blockedModel}`; + logWarn( + `Model ${blockedModel} is unsupported for account ${account.index + 1}. Trying next account/workspace before fallback.`, + { + unsupportedCodexPolicy, + requestedModel: blockedModel, + effectiveModel: blockedModel, + fallbackApplied: false, + fallbackReason: "unsupported-model-entitlement", + }, + ); + retryNextAccountBeforeFallback = true; + break; + } - const parseFiniteIntHeader = (headers: Headers, name: string): number | undefined => { - const raw = headers.get(name); - if (!raw) return undefined; - const parsed = Number.parseInt(raw, 10); - return Number.isFinite(parsed) ? parsed : undefined; - }; + const fallbackModel = + resolveUnsupportedCodexFallbackModel({ + requestedModel: model, + errorBody, + attemptedModels: attemptedUnsupportedFallbackModels, + fallbackOnUnsupportedCodexModel: + allowUnsupportedFallback, + fallbackToGpt52OnUnsupportedGpt53, + customChain: unsupportedCodexFallbackChain, + }); + + if (fallbackModel) { + const previousModel = model ?? "gpt-5-codex"; + const previousModelFamily = modelFamily; + attemptedUnsupportedFallbackModels.add(previousModel); + attemptedUnsupportedFallbackModels.add(fallbackModel); + entitlementCache.markBlocked( + entitlementAccountKey, + previousModel, + "unsupported-model", + ); + capabilityPolicyStore.recordUnsupported( + entitlementAccountKey, + previousModel, + ); + accountManager.refundToken( + account, + previousModelFamily, + previousModel, + ); - const parseResetAtMs = (headers: Headers, prefix: string): number | undefined => { - const resetAfterSeconds = parseFiniteIntHeader( - headers, - `${prefix}-reset-after-seconds`, - ); - if ( - typeof resetAfterSeconds === "number" && - Number.isFinite(resetAfterSeconds) && - resetAfterSeconds > 0 - ) { - return Date.now() + resetAfterSeconds * 1000; - } + model = fallbackModel; + modelFamily = getModelFamily(model); + quotaKey = `${modelFamily}:${model}`; - const resetAtRaw = headers.get(`${prefix}-reset-at`); - if (!resetAtRaw) return undefined; + if ( + transformedBody && + typeof transformedBody === "object" + ) { + transformedBody = { ...transformedBody, model }; + } else { + let fallbackBody: Record = { + model, + }; + if ( + requestInit?.body && + typeof requestInit.body === "string" + ) { + try { + const parsed = JSON.parse( + requestInit.body, + ) as Record; + fallbackBody = { ...parsed, model }; + } catch { + // Keep minimal fallback body if parsing fails. + } + } + transformedBody = fallbackBody as RequestBody; + } - const trimmed = resetAtRaw.trim(); - if (/^\d+$/.test(trimmed)) { - const parsedNumber = Number.parseInt(trimmed, 10); - if (Number.isFinite(parsedNumber) && parsedNumber > 0) { - // Upstream sometimes returns seconds since epoch. - return parsedNumber < 10_000_000_000 ? parsedNumber * 1000 : parsedNumber; - } - } + requestInit = { + ...(requestInit ?? {}), + body: JSON.stringify(transformedBody), + }; + runtimeMetrics.lastError = `Model fallback: ${previousModel} -> ${model}`; + logWarn( + `Model ${previousModel} is unsupported for this ChatGPT account. Falling back to ${model}.`, + { + unsupportedCodexPolicy, + requestedModel: previousModel, + effectiveModel: model, + fallbackApplied: true, + fallbackReason: "unsupported-model-entitlement", + }, + ); + await showToast( + `Model ${previousModel} is not available for this account. Retrying with ${model}.`, + "warning", + { duration: toastDurationMs }, + ); + restartAccountTraversalWithFallback = true; + break; + } - const parsedDate = Date.parse(trimmed); - return Number.isFinite(parsedDate) ? parsedDate : undefined; - }; + if ( + unsupportedModelInfo.isUnsupported && + !allowUnsupportedFallback + ) { + entitlementCache.markBlocked( + entitlementAccountKey, + blockedModel, + "unsupported-model", + ); + capabilityPolicyStore.recordUnsupported( + entitlementAccountKey, + blockedModel, + ); + runtimeMetrics.lastError = `Unsupported model (strict): ${blockedModel}`; + logWarn( + `Model ${blockedModel} is unsupported for this ChatGPT account. Strict policy blocks automatic fallback.`, + { + unsupportedCodexPolicy, + requestedModel: blockedModel, + effectiveModel: blockedModel, + fallbackApplied: false, + fallbackReason: "unsupported-model-entitlement", + }, + ); + await showToast( + `Model ${blockedModel} is not available for this account. Strict policy blocked automatic fallback.`, + "warning", + { duration: toastDurationMs }, + ); + } + if ( + unsupportedModelInfo.isUnsupported && + allowUnsupportedFallback && + !hasRemainingAccounts && + !fallbackModel + ) { + entitlementCache.markBlocked( + entitlementAccountKey, + blockedModel, + "unsupported-model", + ); + capabilityPolicyStore.recordUnsupported( + entitlementAccountKey, + blockedModel, + ); + } + const workspaceErrorCode = + ( + errorBody as + | { error?: { code?: string } } + | undefined + )?.error?.code ?? ""; + const workspaceErrorMessage = + ( + errorBody as + | { error?: { message?: string } } + | undefined + )?.error?.message ?? ""; + const isDisabledWorkspaceError = + isWorkspaceDisabledError( + errorResponse.status, + workspaceErrorCode, + workspaceErrorMessage, + ); - const hasCodexQuotaHeaders = (headers: Headers): boolean => { - const keys = [ - "x-codex-primary-used-percent", - "x-codex-primary-window-minutes", - "x-codex-primary-reset-at", - "x-codex-primary-reset-after-seconds", - "x-codex-secondary-used-percent", - "x-codex-secondary-window-minutes", - "x-codex-secondary-reset-at", - "x-codex-secondary-reset-after-seconds", - ]; - return keys.some((key) => headers.get(key) !== null); - }; + // Handle workspace disabled/expired errors by rotating to the next workspace + // within the same account before falling back to another account. + if (isDisabledWorkspaceError) { + runtimeMetrics.failedRequests++; + runtimeMetrics.lastError = `Workspace disabled for account ${account.index + 1}`; + + if ( + !account.workspaces || + account.workspaces.length === 0 + ) { + logWarn( + `Workspace disabled/expired for account ${account.index + 1} without tracked workspaces. Leaving account enabled.`, + { errorCode: workspaceErrorCode }, + ); + if (hasRemainingAccounts) { + continue accountAttemptLoop; + } + return errorResponse; + } else { + const currentWorkspace = + accountManager.getCurrentWorkspace(account); + const workspaceName = + currentWorkspace?.name ?? + currentWorkspace?.id ?? + "unknown"; + + logWarn( + `Workspace disabled/expired for account ${account.index + 1} - workspace: ${workspaceName}. Rotating to next workspace.`, + { errorCode: workspaceErrorCode }, + ); + + const disabledWorkspace = currentWorkspace + ? accountManager.disableCurrentWorkspace( + account, + currentWorkspace.id, + ) + : false; + let nextWorkspace = disabledWorkspace + ? accountManager.rotateToNextWorkspace(account) + : accountManager.getCurrentWorkspace(account); + if ( + !disabledWorkspace && + (!nextWorkspace || + nextWorkspace.enabled === false) + ) { + nextWorkspace = + accountManager.rotateToNextWorkspace(account); + } + + if (nextWorkspace) { + accountManager.saveToDiskDebounced(); + + const newWorkspaceName = + nextWorkspace.name ?? nextWorkspace.id; + await showToast( + `Workspace ${workspaceName} disabled. Switched to ${newWorkspaceName}.`, + "warning", + { duration: toastDurationMs }, + ); + + logInfo( + `Rotated to workspace ${newWorkspaceName} for account ${account.index + 1}`, + ); + + // Allow the same account to be selected again with fresh request state. + attempted.delete(account.index); + continue accountAttemptLoop; + } + + logWarn( + `All workspaces disabled for account ${account.index + 1}. Disabling account.`, + ); + + accountManager.setAccountEnabled( + account.index, + false, + ); + accountManager.saveToDiskDebounced(); + + await showToast( + `All workspaces disabled for account ${account.index + 1}. Switching to another account.`, + "warning", + { duration: toastDurationMs }, + ); + + // Forget session affinity and continue the outer loop so another + // enabled account can service the request. + sessionAffinityStore?.forgetSession( + sessionAffinityKey, + ); + continue accountAttemptLoop; + } + } - const parseCodexQuotaSnapshot = (headers: Headers, status: number): CodexQuotaSnapshot | null => { - if (!hasCodexQuotaHeaders(headers)) return null; - - const primaryPrefix = "x-codex-primary"; - const secondaryPrefix = "x-codex-secondary"; - const primary: CodexQuotaWindow = { - usedPercent: parseFiniteNumberHeader(headers, `${primaryPrefix}-used-percent`), - windowMinutes: parseFiniteIntHeader(headers, `${primaryPrefix}-window-minutes`), - resetAtMs: parseResetAtMs(headers, primaryPrefix), - }; - const secondary: CodexQuotaWindow = { - usedPercent: parseFiniteNumberHeader(headers, `${secondaryPrefix}-used-percent`), - windowMinutes: parseFiniteIntHeader(headers, `${secondaryPrefix}-window-minutes`), - resetAtMs: parseResetAtMs(headers, secondaryPrefix), - }; - - const planTypeRaw = headers.get("x-codex-plan-type"); - const planType = planTypeRaw && planTypeRaw.trim() ? planTypeRaw.trim() : undefined; - const activeLimit = parseFiniteIntHeader(headers, "x-codex-active-limit"); - - return { status, planType, activeLimit, primary, secondary }; - }; + if ( + errorResponse.status === 403 && + !unsupportedModelInfo.isUnsupported && + !isDisabledWorkspaceError + ) { + entitlementCache.markBlocked( + entitlementAccountKey, + model ?? modelFamily, + "plan-entitlement", + ); + capabilityPolicyStore.recordFailure( + entitlementAccountKey, + capabilityModelKey, + ); + } - const formatQuotaWindowLabel = (windowMinutes: number | undefined): string => { - if (!windowMinutes || !Number.isFinite(windowMinutes) || windowMinutes <= 0) { - return "quota"; - } - if (windowMinutes % 1440 === 0) return `${windowMinutes / 1440}d`; - if (windowMinutes % 60 === 0) return `${windowMinutes / 60}h`; - return `${windowMinutes}m`; - }; + if ( + recoveryHook && + errorBody && + isRecoverableError(errorBody) + ) { + const errorType = detectErrorType(errorBody); + const toastContent = + getRecoveryToastContent(errorType); + await showToast( + `${toastContent.title}: ${toastContent.message}`, + "warning", + { duration: toastDurationMs }, + ); + logDebug( + `[${PLUGIN_NAME}] Recoverable error detected: ${errorType}`, + ); + } - const formatResetAt = (resetAtMs: number | undefined): string | undefined => { - if (!resetAtMs || !Number.isFinite(resetAtMs) || resetAtMs <= 0) return undefined; - const date = new Date(resetAtMs); - if (!Number.isFinite(date.getTime())) return undefined; - - const now = new Date(); - const sameDay = - now.getFullYear() === date.getFullYear() && - now.getMonth() === date.getMonth() && - now.getDate() === date.getDate(); - - const time = date.toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - hour12: false, - }); + // Handle 5xx server errors by rotating to another account + if (response.status >= 500 && response.status < 600) { + logWarn( + `Server error ${response.status} for account ${account.index + 1}. Rotating to next account.`, + ); + runtimeMetrics.failedRequests++; + runtimeMetrics.serverErrors++; + runtimeMetrics.accountRotations++; + runtimeMetrics.lastError = `HTTP ${response.status}`; + const serverRetryAfterMs = parseRetryAfterHintMs( + response.headers, + ); + const policy = evaluateFailurePolicy( + { + kind: "server", + failoverMode, + serverRetryAfterMs: + serverRetryAfterMs ?? undefined, + }, + { serverCooldownMs: serverErrorCooldownMs }, + ); + if (policy.refundToken) { + accountManager.refundToken( + account, + modelFamily, + model, + ); + } + if (policy.recordFailure) { + accountManager.recordFailure( + account, + modelFamily, + model, + ); + capabilityPolicyStore.recordFailure( + entitlementAccountKey, + capabilityModelKey, + ); + } + if ( + policy.retrySameAccount && + sameAccountRetryCount < maxSameAccountRetries + ) { + sameAccountRetryCount += 1; + runtimeMetrics.sameAccountRetries += 1; + const retryDelayMs = Math.max( + MIN_BACKOFF_MS, + Math.floor(policy.retryDelayMs ?? 500), + ); + await sleep(addJitter(retryDelayMs, 0.2)); + continue; + } + if ( + typeof policy.cooldownMs === "number" && + policy.cooldownReason + ) { + accountManager.markAccountCoolingDown( + account, + policy.cooldownMs, + policy.cooldownReason, + ); + accountManager.saveToDiskDebounced(); + } + sessionAffinityStore?.forgetSession( + sessionAffinityKey, + ); + break; + } - if (sameDay) return time; - const day = date.toLocaleDateString(undefined, { month: "short", day: "2-digit" }); - return `${time} on ${day}`; - }; + if (rateLimit) { + runtimeMetrics.rateLimitedResponses++; + const { attempt, delayMs } = getRateLimitBackoff( + account.index, + quotaKey, + rateLimit.retryAfterMs, + ); + preemptiveQuotaScheduler.markRateLimited( + quotaScheduleKey, + delayMs, + ); + const waitLabel = formatWaitTime(delayMs); + + if (delayMs <= RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS) { + if ( + accountManager.shouldShowAccountToast( + account.index, + rateLimitToastDebounceMs, + ) + ) { + await showToast( + `Rate limited. Retrying in ${waitLabel} (attempt ${attempt})...`, + "warning", + { duration: toastDurationMs }, + ); + accountManager.markToastShown(account.index); + } + + await sleep( + addJitter(Math.max(MIN_BACKOFF_MS, delayMs), 0.2), + ); + continue; + } - const formatCodexQuotaLine = (snapshot: CodexQuotaSnapshot): string => { - const summarizeWindow = (label: string, window: CodexQuotaWindow): string => { - const used = window.usedPercent; - const left = - typeof used === "number" && Number.isFinite(used) - ? Math.max(0, Math.min(100, Math.round(100 - used))) - : undefined; - const reset = formatResetAt(window.resetAtMs); - let summary = label; - if (left !== undefined) summary = `${summary} ${left}% left`; - if (reset) summary = `${summary} (resets ${reset})`; - return summary; - }; - - const primaryLabel = formatQuotaWindowLabel(snapshot.primary.windowMinutes); - const secondaryLabel = formatQuotaWindowLabel(snapshot.secondary.windowMinutes); - const parts = [ - summarizeWindow(primaryLabel, snapshot.primary), - summarizeWindow(secondaryLabel, snapshot.secondary), - ]; - if (snapshot.planType) parts.push(`plan:${snapshot.planType}`); - if (typeof snapshot.activeLimit === "number" && Number.isFinite(snapshot.activeLimit)) { - parts.push(`active:${snapshot.activeLimit}`); - } - if (snapshot.status === 429) parts.push("rate-limited"); - return parts.join(", "); - }; + accountManager.markRateLimitedWithReason( + account, + delayMs, + modelFamily, + parseRateLimitReason(rateLimit.code), + model, + ); + accountManager.recordRateLimit( + account, + modelFamily, + model, + ); + account.lastSwitchReason = "rate-limit"; + sessionAffinityStore?.forgetSession( + sessionAffinityKey, + ); + runtimeMetrics.accountRotations++; + accountManager.saveToDiskDebounced(); + logWarn( + `Rate limited. Rotating account ${account.index + 1} (${account.email ?? "unknown"}).`, + ); - const fetchCodexQuotaSnapshot = async (params: { - accountId: string; - accessToken: string; - }): Promise => { - const QUOTA_PROBE_MODELS = ["gpt-5-codex", "gpt-5.3-codex", "gpt-5.2-codex"]; - let lastError: Error | null = null; + if ( + accountManager.getAccountCount() > 1 && + accountManager.shouldShowAccountToast( + account.index, + rateLimitToastDebounceMs, + ) + ) { + await showToast( + `Rate limited. Switching accounts (retry in ${waitLabel}).`, + "warning", + { duration: toastDurationMs }, + ); + accountManager.markToastShown(account.index); + } + break; + } + if ( + !rateLimit && + !unsupportedModelInfo.isUnsupported && + errorResponse.status !== 403 + ) { + capabilityPolicyStore.recordFailure( + entitlementAccountKey, + capabilityModelKey, + ); + } + runtimeMetrics.failedRequests++; + runtimeMetrics.lastError = `HTTP ${response.status}`; + return errorResponse; + } - for (const model of QUOTA_PROBE_MODELS) { - try { - const instructions = await getCodexInstructions(model); - const probeBody: RequestBody = { - model, - stream: true, - store: false, - include: ["reasoning.encrypted_content"], - instructions, - input: [ + resetRateLimitBackoff(account.index, quotaKey); + runtimeMetrics.cumulativeLatencyMs += fetchLatencyMs; + let responseForSuccess = response; + if (isStreaming) { + const streamFallbackCandidateOrder = [ + account.index, + ...accountManager + .getAccountsSnapshot() + .map((candidate) => candidate.index) + .filter((index) => index !== account.index), + ]; + responseForSuccess = withStreamingFailover( + response, + async (failoverAttempt, emittedBytes) => { + if (abortSignal?.aborted) { + return null; + } + runtimeMetrics.streamFailoverAttempts += 1; + + for (const candidateIndex of streamFallbackCandidateOrder) { + if (abortSignal?.aborted) { + return null; + } + if ( + !accountManager.isAccountAvailableForFamily( + candidateIndex, + modelFamily, + model, + ) + ) { + continue; + } + + const fallbackAccount = + accountManager.getAccountByIndex( + candidateIndex, + ); + if (!fallbackAccount) continue; + + let fallbackAuth = accountManager.toAuthDetails( + fallbackAccount, + ) as OAuthAuthDetails; + try { + if ( + shouldRefreshToken( + fallbackAuth, + tokenRefreshSkewMs, + ) + ) { + fallbackAuth = (await refreshAndUpdateToken( + fallbackAuth, + client, + )) as OAuthAuthDetails; + accountManager.updateFromAuth( + fallbackAccount, + fallbackAuth, + ); + accountManager.clearAuthFailures( + fallbackAccount, + ); + accountManager.saveToDiskDebounced(); + } + } catch (refreshError) { + logWarn( + `Stream failover refresh failed for account ${fallbackAccount.index + 1}.`, + { + error: + refreshError instanceof Error + ? refreshError.message + : String(refreshError), + }, + ); + continue; + } + + const fallbackStoredAccountId = + fallbackAccount.accountId; + const fallbackStoredAccountIdSource = + fallbackAccount.accountIdSource; + const fallbackStoredEmail = fallbackAccount.email; + const hadFallbackAccountId = + !!fallbackStoredAccountId; + const fallbackRuntimeIdentity = + resolveRuntimeRequestIdentity({ + storedAccountId: fallbackStoredAccountId, + source: fallbackStoredAccountIdSource, + storedEmail: fallbackStoredEmail, + accessToken: fallbackAuth.access, + idToken: fallbackAuth.idToken, + }); + const fallbackTokenAccountId = + fallbackRuntimeIdentity.tokenAccountId; + const fallbackAccountId = + fallbackRuntimeIdentity.accountId; + if (!fallbackAccountId) { + continue; + } + const fallbackResolvedEmail = + fallbackRuntimeIdentity.email; + const fallbackEntitlementAccountKey = + resolveEntitlementAccountKey({ + accountId: + fallbackStoredAccountId ?? + fallbackAccountId, + email: fallbackResolvedEmail, + refreshToken: fallbackAccount.refreshToken, + index: fallbackAccount.index, + }); + const fallbackEntitlementBlock = + entitlementCache.isBlocked( + fallbackEntitlementAccountKey, + model ?? modelFamily, + ); + if (fallbackEntitlementBlock.blocked) { + runtimeMetrics.accountRotations++; + runtimeMetrics.lastError = `Entitlement cached block for account ${fallbackAccount.index + 1}`; + logWarn( + `Skipping account ${fallbackAccount.index + 1} due to cached entitlement block (${formatWaitTime(fallbackEntitlementBlock.waitMs)} remaining).`, + ); + continue; + } + + if ( + !accountManager.consumeToken( + fallbackAccount, + modelFamily, + model, + ) + ) { + continue; + } + fallbackAccount.accountId = fallbackAccountId; + if ( + !hadFallbackAccountId && + fallbackTokenAccountId && + fallbackAccountId === fallbackTokenAccountId + ) { + fallbackAccount.accountIdSource = + fallbackStoredAccountIdSource ?? "token"; + } + if (fallbackResolvedEmail) { + fallbackAccount.email = fallbackResolvedEmail; + } + + const fallbackHeaders = createCodexHeaders( + requestInit, + fallbackAccountId, + fallbackAuth.access, + { + model, + promptCacheKey: effectivePromptCacheKey, + }, + ); + + const fallbackController = new AbortController(); + const fallbackTimeoutId = setTimeout( + () => + fallbackController.abort( + new Error("Request timeout"), + ), + fetchTimeoutMs, + ); + const onFallbackAbort = abortSignal + ? () => + fallbackController.abort( + abortSignal.reason ?? + new Error("Aborted by user"), + ) + : null; + if (abortSignal && onFallbackAbort) { + abortSignal.addEventListener( + "abort", + onFallbackAbort, + { + once: true, + }, + ); + } + + try { + runtimeMetrics.totalRequests++; + const fallbackResponse = await fetch( + url, + applyProxyCompatibleInit(url, { + ...requestInit, + headers: fallbackHeaders, + signal: fallbackController.signal, + }), + ); + const fallbackSnapshot = + readQuotaSchedulerSnapshot( + fallbackResponse.headers, + fallbackResponse.status, + ); + if (fallbackSnapshot) { + preemptiveQuotaScheduler.update( + `${fallbackEntitlementAccountKey}:${model ?? modelFamily}`, + fallbackSnapshot, + ); + } + if (!fallbackResponse.ok) { + try { + await fallbackResponse.body?.cancel(); + } catch { + // Best effort cleanup before trying next fallback account. + } + if (fallbackResponse.status === 429) { + const retryAfterMs = + parseRetryAfterHintMs( + fallbackResponse.headers, + ) ?? 60_000; + accountManager.markRateLimitedWithReason( + fallbackAccount, + retryAfterMs, + modelFamily, + "quota", + model, + ); + accountManager.recordRateLimit( + fallbackAccount, + modelFamily, + model, + ); + } else { + accountManager.recordFailure( + fallbackAccount, + modelFamily, + model, + ); + } + capabilityPolicyStore.recordFailure( + fallbackEntitlementAccountKey, + capabilityModelKey, + ); + continue; + } + + successAccountForResponse = fallbackAccount; + successEntitlementAccountKey = + fallbackEntitlementAccountKey; + runtimeMetrics.streamFailoverRecoveries += 1; + if (fallbackAccount.index !== account.index) { + runtimeMetrics.streamFailoverCrossAccountRecoveries += 1; + runtimeMetrics.accountRotations += 1; + sessionAffinityStore?.remember( + sessionAffinityKey, + fallbackAccount.index, + ); + } + + logInfo( + `Recovered stream via failover attempt ${failoverAttempt} using account ${fallbackAccount.index + 1}.`, + { emittedBytes }, + ); + return fallbackResponse; + } catch (streamFailoverError) { + accountManager.refundToken( + fallbackAccount, + modelFamily, + model, + ); + accountManager.recordFailure( + fallbackAccount, + modelFamily, + model, + ); + capabilityPolicyStore.recordFailure( + fallbackEntitlementAccountKey, + capabilityModelKey, + ); + logWarn( + `Stream failover attempt ${failoverAttempt} failed for account ${fallbackAccount.index + 1}.`, + { + emittedBytes, + error: + streamFailoverError instanceof Error + ? streamFailoverError.message + : String(streamFailoverError), + }, + ); + } finally { + clearTimeout(fallbackTimeoutId); + if (abortSignal && onFallbackAbort) { + abortSignal.removeEventListener( + "abort", + onFallbackAbort, + ); + } + } + } + + return null; + }, + { + maxFailovers: streamFailoverMax, + softTimeoutMs: streamFailoverSoftTimeoutMs, + hardTimeoutMs: streamFailoverHardTimeoutMs, + requestInstanceId: + requestCorrelationId ?? undefined, + }, + ); + } + const successResponse = await handleSuccessResponse( + responseForSuccess, + isStreaming, { - type: "message", - role: "user", - content: [{ type: "input_text", text: "quota ping" }], + streamStallTimeoutMs, }, - ], - reasoning: { effort: "none", summary: "auto" }, - text: { verbosity: "low" }, - }; - - const headers = createCodexHeaders(undefined, params.accountId, params.accessToken, { - model, - }); - headers.set("content-type", "application/json; charset=utf-8"); - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 15_000); - let response: Response; - try { - response = await fetch(`${CODEX_BASE_URL}/codex/responses`, { - method: "POST", - headers, - body: JSON.stringify(probeBody), - signal: controller.signal, - }); - } finally { - clearTimeout(timeout); - } + ); - const snapshot = parseCodexQuotaSnapshot(response.headers, response.status); - if (snapshot) { - // We only need headers; cancel the SSE stream immediately. - try { - await response.body?.cancel(); - } catch { - // Ignore cancellation failures. + if (!isStreaming && emptyResponseMaxRetries > 0) { + const clonedResponse = successResponse.clone(); + try { + const bodyText = await clonedResponse.text(); + const parsedBody = bodyText + ? (JSON.parse(bodyText) as unknown) + : null; + if (isEmptyResponse(parsedBody)) { + if ( + emptyResponseRetries < emptyResponseMaxRetries + ) { + emptyResponseRetries++; + runtimeMetrics.emptyResponseRetries++; + logWarn( + `Empty response received (attempt ${emptyResponseRetries}/${emptyResponseMaxRetries}). Retrying...`, + ); + await showToast( + `Empty response. Retrying (${emptyResponseRetries}/${emptyResponseMaxRetries})...`, + "warning", + { duration: toastDurationMs }, + ); + accountManager.refundToken( + account, + modelFamily, + model, + ); + accountManager.recordFailure( + account, + modelFamily, + model, + ); + capabilityPolicyStore.recordFailure( + entitlementAccountKey, + capabilityModelKey, + ); + const emptyPolicy = evaluateFailurePolicy({ + kind: "empty-response", + failoverMode, + }); + if ( + emptyPolicy.retrySameAccount && + sameAccountRetryCount < maxSameAccountRetries + ) { + sameAccountRetryCount += 1; + runtimeMetrics.sameAccountRetries += 1; + const retryDelayMs = Math.max( + 0, + Math.floor( + emptyPolicy.retryDelayMs ?? + emptyResponseRetryDelayMs, + ), + ); + if (retryDelayMs > 0) { + await sleep(addJitter(retryDelayMs, 0.2)); + } + continue; + } + sessionAffinityStore?.forgetSession( + sessionAffinityKey, + ); + await sleep( + addJitter(emptyResponseRetryDelayMs, 0.2), + ); + break; + } + logWarn( + `Empty response after ${emptyResponseMaxRetries} retries. Returning as-is.`, + ); + } + } catch { + // Intentionally empty: non-JSON response bodies should be returned as-is + } } - return snapshot; - } - if (!response.ok) { - const bodyText = await response.text().catch(() => ""); - let errorBody: unknown = undefined; - try { - errorBody = bodyText ? (JSON.parse(bodyText) as unknown) : undefined; - } catch { - errorBody = { error: { message: bodyText } }; + if (successAccountForResponse.index !== account.index) { + accountManager.markSwitched( + successAccountForResponse, + "rotation", + modelFamily, + ); } - - const unsupportedInfo = getUnsupportedCodexModelInfo(errorBody); - if (unsupportedInfo.isUnsupported) { - lastError = new Error( - unsupportedInfo.message ?? `Model '${model}' unsupported for this account`, + const successAccountKey = successEntitlementAccountKey; + accountManager.recordSuccess( + successAccountForResponse, + modelFamily, + model, + ); + capabilityPolicyStore.recordSuccess( + successAccountKey, + capabilityModelKey, + ); + entitlementCache.clear( + successAccountKey, + capabilityModelKey, + ); + sessionAffinityStore?.remember( + sessionAffinityKey, + successAccountForResponse.index, + ); + runtimeMetrics.successfulRequests++; + runtimeMetrics.lastError = null; + if ( + lastCodexCliActiveSyncIndex !== + successAccountForResponse.index + ) { + void accountManager.syncCodexCliActiveSelectionForIndex( + successAccountForResponse.index, ); - continue; + lastCodexCliActiveSyncIndex = + successAccountForResponse.index; } + return successResponse; + } + if (retryNextAccountBeforeFallback) { + retryNextAccountBeforeFallback = false; + continue; + } - const message = - (typeof (errorBody as { error?: { message?: unknown } })?.error?.message === "string" - ? (errorBody as { error?: { message?: string } }).error?.message - : bodyText) || `HTTP ${response.status}`; - throw new Error(message); + if (restartAccountTraversalWithFallback) { + break; } + } - lastError = new Error("Codex response did not include quota headers"); - } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); + if (restartAccountTraversalWithFallback) { + continue; } - } - throw lastError ?? new Error("Failed to fetch quotas"); - }; + const waitMs = accountManager.getMinWaitTimeForFamily( + modelFamily, + model, + ); + const count = accountManager.getAccountCount(); - const runAccountCheck = async (deepProbe: boolean): Promise => { - const loadedStorage = await hydrateEmails(await loadAccounts()); - const workingStorage = loadedStorage - ? { + if ( + retryAllAccountsRateLimited && + count > 0 && + waitMs > 0 && + (retryAllAccountsMaxWaitMs === 0 || + waitMs <= retryAllAccountsMaxWaitMs) && + allRateLimitedRetries < retryAllAccountsMaxRetries + ) { + const countdownMessage = `All ${count} account(s) rate-limited. Waiting`; + await sleepWithCountdown({ + totalMs: addJitter(waitMs, 0.2), + message: countdownMessage, + sleep, + showToast, + formatWaitTime, + toastDurationMs, + abortSignal, + }); + allRateLimitedRetries++; + continue; + } + + const waitLabel = + waitMs > 0 ? formatWaitTime(waitMs) : "a bit"; + const message = + count === 0 + ? "No Codex accounts configured. Run `codex login`." + : waitMs > 0 + ? `All ${count} account(s) are rate-limited. Try again in ${waitLabel} or add another account with \`codex login\`.` + : `All ${count} account(s) failed (server errors or auth issues). Check account health with \`codex-health\`.`; + runtimeMetrics.failedRequests++; + runtimeMetrics.lastError = message; + return new Response(JSON.stringify({ error: { message } }), { + status: waitMs > 0 ? 429 : 503, + headers: { + "content-type": "application/json; charset=utf-8", + }, + }); + } + } finally { + clearCorrelationId(); + } + }, + }; + } finally { + resolveMutex?.(); + loaderMutex = null; + } + }, + methods: [ + { + label: AUTH_LABELS.OAUTH, + type: "oauth" as const, + authorize: async (inputs?: Record) => { + const authPluginConfig = loadPluginConfig(); + applyUiRuntimeFromConfig(authPluginConfig, setUiRuntimeOptions); + applyAccountStorageScope(authPluginConfig); + + const accounts: TokenSuccessWithAccount[] = []; + const noBrowser = + inputs?.manual === "true" || + inputs?.noBrowser === "true" || + inputs?.["no-browser"] === "true"; + const useManualMode = noBrowser || isBrowserLaunchSuppressed(); + const explicitLoginMode = + inputs?.loginMode === "fresh" || inputs?.loginMode === "add" + ? inputs.loginMode + : null; + + let startFresh = explicitLoginMode === "fresh"; + let refreshAccountIndex: number | undefined; + + const runAccountCheck = async ( + deepProbe: boolean, + ): Promise => { + const loadedStorage = await hydrateEmails(await loadAccounts()); + const workingStorage = loadedStorage + ? { ...loadedStorage, - accounts: loadedStorage.accounts.map((account) => ({ ...account })), + accounts: loadedStorage.accounts.map((account) => ({ + ...account, + })), activeIndexByFamily: loadedStorage.activeIndexByFamily ? { ...loadedStorage.activeIndexByFamily } : {}, } - : { version: 3 as const, accounts: [], activeIndex: 0, activeIndexByFamily: {} }; + : { + version: 3 as const, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }; - if (workingStorage.accounts.length === 0) { - console.log("\nNo accounts to check.\n"); - return; - } + if (workingStorage.accounts.length === 0) { + console.log("\nNo accounts to check.\n"); + return; + } - const flaggedStorage = await loadFlaggedAccounts(); - let storageChanged = false; - let flaggedChanged = false; - const removeFromActive = new Set(); - const total = workingStorage.accounts.length; - let ok = 0; - let disabled = 0; - let errors = 0; + const flaggedStorage = await loadFlaggedAccounts(); + let storageChanged = false; + let flaggedChanged = false; + const removeFromActive = new Set(); + const total = workingStorage.accounts.length; + let ok = 0; + let disabled = 0; + let errors = 0; + + console.log( + `\nChecking ${deepProbe ? "full account health" : "quotas"} for all accounts...\n`, + ); - console.log( - `\nChecking ${deepProbe ? "full account health" : "quotas"} for all accounts...\n`, - ); + for (let i = 0; i < total; i += 1) { + const account = workingStorage.accounts[i]; + if (!account) continue; + const label = + account.email ?? account.accountLabel ?? `Account ${i + 1}`; + if (account.enabled === false) { + disabled += 1; + console.log(`[${i + 1}/${total}] ${label}: DISABLED`); + continue; + } - for (let i = 0; i < total; i += 1) { - const account = workingStorage.accounts[i]; - if (!account) continue; - const label = account.email ?? account.accountLabel ?? `Account ${i + 1}`; - if (account.enabled === false) { - disabled += 1; - console.log(`[${i + 1}/${total}] ${label}: DISABLED`); - continue; + try { + // If we already have a valid cached access token, don't force-refresh. + // This avoids flagging accounts where the refresh token has been burned + // but the access token is still valid (same behavior as Codex CLI). + const nowMs = Date.now(); + let accessToken: string | null = null; + let tokenAccountId: string | undefined; + let authDetail = "OK"; + if ( + account.accessToken && + (typeof account.expiresAt !== "number" || + !Number.isFinite(account.expiresAt) || + account.expiresAt > nowMs) + ) { + accessToken = account.accessToken; + authDetail = "OK (cached access)"; + + tokenAccountId = extractAccountId(account.accessToken); + if ( + tokenAccountId && + shouldUpdateAccountIdFromToken( + account.accountIdSource, + account.accountId, + ) && + tokenAccountId !== account.accountId + ) { + account.accountId = tokenAccountId; + account.accountIdSource = "token"; + storageChanged = true; + } } - try { - // If we already have a valid cached access token, don't force-refresh. - // This avoids flagging accounts where the refresh token has been burned - // but the access token is still valid (same behavior as Codex CLI). - const nowMs = Date.now(); - let accessToken: string | null = null; - let tokenAccountId: string | undefined = undefined; - let authDetail = "OK"; + // If Codex CLI has a valid cached access token for this email, use it + // instead of forcing a refresh. + if (!accessToken) { + const cached = await lookupCodexCliTokensByEmail( + account.email, + ); if ( - account.accessToken && - (typeof account.expiresAt !== "number" || - !Number.isFinite(account.expiresAt) || - account.expiresAt > nowMs) + cached && + (typeof cached.expiresAt !== "number" || + !Number.isFinite(cached.expiresAt) || + cached.expiresAt > nowMs) ) { - accessToken = account.accessToken; - authDetail = "OK (cached access)"; + accessToken = cached.accessToken; + authDetail = "OK (Codex CLI cache)"; - tokenAccountId = extractAccountId(account.accessToken); if ( - tokenAccountId && - shouldUpdateAccountIdFromToken(account.accountIdSource, account.accountId) && - tokenAccountId !== account.accountId + cached.refreshToken && + cached.refreshToken !== account.refreshToken ) { - account.accountId = tokenAccountId; - account.accountIdSource = "token"; + account.refreshToken = cached.refreshToken; storageChanged = true; } - - } - - // If Codex CLI has a valid cached access token for this email, use it - // instead of forcing a refresh. - if (!accessToken) { - const cached = await lookupCodexCliTokensByEmail(account.email); if ( - cached && - (typeof cached.expiresAt !== "number" || - !Number.isFinite(cached.expiresAt) || - cached.expiresAt > nowMs) + cached.accessToken && + cached.accessToken !== account.accessToken ) { - accessToken = cached.accessToken; - authDetail = "OK (Codex CLI cache)"; - - if (cached.refreshToken && cached.refreshToken !== account.refreshToken) { - account.refreshToken = cached.refreshToken; - storageChanged = true; - } - if (cached.accessToken && cached.accessToken !== account.accessToken) { - account.accessToken = cached.accessToken; - storageChanged = true; - } - if (cached.expiresAt !== account.expiresAt) { - account.expiresAt = cached.expiresAt; - storageChanged = true; - } - - const hydratedEmail = sanitizeEmail( - extractAccountEmail(cached.accessToken), - ); - if (hydratedEmail && hydratedEmail !== account.email) { - account.email = hydratedEmail; - storageChanged = true; - } - - tokenAccountId = extractAccountId(cached.accessToken); - if ( - tokenAccountId && - shouldUpdateAccountIdFromToken(account.accountIdSource, account.accountId) && - tokenAccountId !== account.accountId - ) { - account.accountId = tokenAccountId; - account.accountIdSource = "token"; - storageChanged = true; - } - } - } - - if (!accessToken) { - const refreshResult = await queuedRefresh(account.refreshToken); - if (refreshResult.type !== "success") { - errors += 1; - const message = - refreshResult.message ?? refreshResult.reason ?? "refresh failed"; - console.log(`[${i + 1}/${total}] ${label}: ERROR (${message})`); - if (deepProbe && isFlaggableFailure(refreshResult)) { - const existingIndex = flaggedStorage.accounts.findIndex( - (flagged) => flagged.refreshToken === account.refreshToken, - ); - const flaggedRecord: FlaggedAccountMetadataV1 = { - ...account, - flaggedAt: Date.now(), - flaggedReason: "token-invalid", - lastError: message, - }; - if (existingIndex >= 0) { - flaggedStorage.accounts[existingIndex] = flaggedRecord; - } else { - flaggedStorage.accounts.push(flaggedRecord); - } - removeFromActive.add(account.refreshToken); - flaggedChanged = true; - } - continue; - } - - accessToken = refreshResult.access; - authDetail = "OK"; - if (refreshResult.refresh !== account.refreshToken) { - account.refreshToken = refreshResult.refresh; - storageChanged = true; - } - if (refreshResult.access && refreshResult.access !== account.accessToken) { - account.accessToken = refreshResult.access; + account.accessToken = cached.accessToken; storageChanged = true; } - if ( - typeof refreshResult.expires === "number" && - refreshResult.expires !== account.expiresAt - ) { - account.expiresAt = refreshResult.expires; + if (cached.expiresAt !== account.expiresAt) { + account.expiresAt = cached.expiresAt; storageChanged = true; } + const hydratedEmail = sanitizeEmail( - extractAccountEmail(refreshResult.access, refreshResult.idToken), + extractAccountEmail(cached.accessToken), ); if (hydratedEmail && hydratedEmail !== account.email) { account.email = hydratedEmail; storageChanged = true; } - tokenAccountId = extractAccountId(refreshResult.access); + + tokenAccountId = extractAccountId(cached.accessToken); if ( tokenAccountId && - shouldUpdateAccountIdFromToken(account.accountIdSource, account.accountId) && + shouldUpdateAccountIdFromToken( + account.accountIdSource, + account.accountId, + ) && tokenAccountId !== account.accountId ) { account.accountId = tokenAccountId; @@ -3056,200 +2610,316 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { storageChanged = true; } } + } - if (!accessToken) { - throw new Error("Missing access token after refresh"); + if (!accessToken) { + const refreshResult = await queuedRefresh( + account.refreshToken, + ); + if (refreshResult.type !== "success") { + errors += 1; + const message = + refreshResult.message ?? + refreshResult.reason ?? + "refresh failed"; + console.log( + `[${i + 1}/${total}] ${label}: ERROR (${message})`, + ); + if (deepProbe && isFlaggableFailure(refreshResult)) { + const existingIndex = flaggedStorage.accounts.findIndex( + (flagged) => + flagged.refreshToken === account.refreshToken, + ); + const flaggedRecord: FlaggedAccountMetadataV1 = { + ...account, + flaggedAt: Date.now(), + flaggedReason: "token-invalid", + lastError: message, + }; + if (existingIndex >= 0) { + flaggedStorage.accounts[existingIndex] = + flaggedRecord; + } else { + flaggedStorage.accounts.push(flaggedRecord); + } + removeFromActive.add(account.refreshToken); + flaggedChanged = true; + } + continue; } - if (deepProbe) { - ok += 1; - const detail = - tokenAccountId - ? `${authDetail} (id:${tokenAccountId.slice(-6)})` - : authDetail; - console.log(`[${i + 1}/${total}] ${label}: ${detail}`); - continue; + accessToken = refreshResult.access; + authDetail = "OK"; + if (refreshResult.refresh !== account.refreshToken) { + account.refreshToken = refreshResult.refresh; + storageChanged = true; + } + if ( + refreshResult.access && + refreshResult.access !== account.accessToken + ) { + account.accessToken = refreshResult.access; + storageChanged = true; + } + if ( + typeof refreshResult.expires === "number" && + refreshResult.expires !== account.expiresAt + ) { + account.expiresAt = refreshResult.expires; + storageChanged = true; + } + const hydratedEmail = sanitizeEmail( + extractAccountEmail( + refreshResult.access, + refreshResult.idToken, + ), + ); + if (hydratedEmail && hydratedEmail !== account.email) { + account.email = hydratedEmail; + storageChanged = true; + } + tokenAccountId = extractAccountId(refreshResult.access); + if ( + tokenAccountId && + shouldUpdateAccountIdFromToken( + account.accountIdSource, + account.accountId, + ) && + tokenAccountId !== account.accountId + ) { + account.accountId = tokenAccountId; + account.accountIdSource = "token"; + storageChanged = true; } + } - try { - const requestAccountId = - resolveRequestAccountId( - account.accountId, - account.accountIdSource, - tokenAccountId, - ) ?? - tokenAccountId ?? - account.accountId; + if (!accessToken) { + throw new Error("Missing access token after refresh"); + } - if (!requestAccountId) { - throw new Error("Missing accountId for quota probe"); - } + if (deepProbe) { + ok += 1; + const detail = tokenAccountId + ? `${authDetail} (id:${tokenAccountId.slice(-6)})` + : authDetail; + console.log(`[${i + 1}/${total}] ${label}: ${detail}`); + continue; + } - const snapshot = await fetchCodexQuotaSnapshot({ - accountId: requestAccountId, - accessToken, - }); - ok += 1; - console.log( - `[${i + 1}/${total}] ${label}: ${formatCodexQuotaLine(snapshot)}`, - ); - } catch (error) { - errors += 1; - const message = error instanceof Error ? error.message : String(error); - console.log( - `[${i + 1}/${total}] ${label}: ERROR (${message.slice(0, 160)})`, - ); + try { + const requestAccountId = + resolveRequestAccountId( + account.accountId, + account.accountIdSource, + tokenAccountId, + ) ?? + tokenAccountId ?? + account.accountId; + + if (!requestAccountId) { + throw new Error("Missing accountId for quota probe"); } + + const snapshot = await fetchCodexQuotaSnapshot({ + accountId: requestAccountId, + accessToken, + }); + ok += 1; + console.log( + `[${i + 1}/${total}] ${label}: ${formatQuotaSnapshotLine(snapshot)}`, + ); } catch (error) { errors += 1; - const message = error instanceof Error ? error.message : String(error); - console.log(`[${i + 1}/${total}] ${label}: ERROR (${message.slice(0, 120)})`); + const message = + error instanceof Error ? error.message : String(error); + console.log( + `[${i + 1}/${total}] ${label}: ERROR (${message.slice(0, 160)})`, + ); } - } - - if (removeFromActive.size > 0) { - workingStorage.accounts = workingStorage.accounts.filter( - (account) => !removeFromActive.has(account.refreshToken), - ); - clampActiveIndices(workingStorage); - storageChanged = true; - } - - if (storageChanged) { - await saveAccounts(workingStorage); - invalidateAccountManagerCache(); - } - if (flaggedChanged) { - await saveFlaggedAccounts(flaggedStorage); - } - - console.log(""); - console.log(`Results: ${ok} ok, ${errors} error, ${disabled} disabled`); - if (removeFromActive.size > 0) { + } catch (error) { + errors += 1; + const message = + error instanceof Error ? error.message : String(error); console.log( - `Moved ${removeFromActive.size} account(s) to flagged pool (invalid refresh token).`, + `[${i + 1}/${total}] ${label}: ERROR (${message.slice(0, 120)})`, ); } - console.log(""); - }; + } - const verifyFlaggedAccounts = async (): Promise => { - const flaggedStorage = await loadFlaggedAccounts(); - if (flaggedStorage.accounts.length === 0) { - console.log("\nNo flagged accounts to verify.\n"); - return; - } + if (removeFromActive.size > 0) { + workingStorage.accounts = workingStorage.accounts.filter( + (account) => !removeFromActive.has(account.refreshToken), + ); + clampActiveIndices(workingStorage, MODEL_FAMILIES); + storageChanged = true; + } - console.log("\nVerifying flagged accounts...\n"); - const remaining: FlaggedAccountMetadataV1[] = []; - const restored: TokenSuccessWithAccount[] = []; + if (storageChanged) { + await saveAccounts(workingStorage); + invalidateAccountManagerCache(); + } + if (flaggedChanged) { + await saveFlaggedAccounts(flaggedStorage); + } - for (let i = 0; i < flaggedStorage.accounts.length; i += 1) { - const flagged = flaggedStorage.accounts[i]; - if (!flagged) continue; - const label = flagged.email ?? flagged.accountLabel ?? `Flagged ${i + 1}`; - try { - const cached = await lookupCodexCliTokensByEmail(flagged.email); - const now = Date.now(); - if ( - cached && - typeof cached.expiresAt === "number" && - Number.isFinite(cached.expiresAt) && - cached.expiresAt > now - ) { - const refreshToken = - typeof cached.refreshToken === "string" && cached.refreshToken.trim() - ? cached.refreshToken.trim() - : flagged.refreshToken; - const resolved = resolveAccountSelection({ - type: "success", - access: cached.accessToken, - refresh: refreshToken, - expires: cached.expiresAt, - multiAccount: true, - }); - if (!resolved.accountIdOverride && flagged.accountId) { - resolved.accountIdOverride = flagged.accountId; - resolved.accountIdSource = flagged.accountIdSource ?? "manual"; - } - if (!resolved.accountLabel && flagged.accountLabel) { - resolved.accountLabel = flagged.accountLabel; - } - restored.push(resolved); - console.log( - `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: RESTORED (Codex CLI cache)`, - ); - continue; - } + console.log(""); + console.log( + `Results: ${ok} ok, ${errors} error, ${disabled} disabled`, + ); + if (removeFromActive.size > 0) { + console.log( + `Moved ${removeFromActive.size} account(s) to flagged pool (invalid refresh token).`, + ); + } + console.log(""); + }; - const refreshResult = await queuedRefresh(flagged.refreshToken); - if (refreshResult.type !== "success") { - console.log( - `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: STILL FLAGGED (${refreshResult.message ?? refreshResult.reason ?? "refresh failed"})`, - ); - remaining.push(flagged); - continue; - } + const verifyFlaggedAccounts = async (): Promise => { + const flaggedStorage = await loadFlaggedAccounts(); + if (flaggedStorage.accounts.length === 0) { + console.log("\nNo flagged accounts to verify.\n"); + return; + } + + console.log("\nVerifying flagged accounts...\n"); + const remaining: FlaggedAccountMetadataV1[] = []; + const restored: TokenSuccessWithAccount[] = []; - const resolved = resolveAccountSelection(refreshResult); + for (let i = 0; i < flaggedStorage.accounts.length; i += 1) { + const flagged = flaggedStorage.accounts[i]; + if (!flagged) continue; + const label = + flagged.email ?? flagged.accountLabel ?? `Flagged ${i + 1}`; + try { + const cached = await lookupCodexCliTokensByEmail( + flagged.email, + ); + const now = Date.now(); + if ( + cached && + typeof cached.expiresAt === "number" && + Number.isFinite(cached.expiresAt) && + cached.expiresAt > now + ) { + const refreshToken = + typeof cached.refreshToken === "string" && + cached.refreshToken.trim() + ? cached.refreshToken.trim() + : flagged.refreshToken; + const resolved = resolveTokenSuccessAccount({ + type: "success", + access: cached.accessToken, + refresh: refreshToken, + expires: cached.expiresAt, + multiAccount: true, + }); if (!resolved.accountIdOverride && flagged.accountId) { resolved.accountIdOverride = flagged.accountId; - resolved.accountIdSource = flagged.accountIdSource ?? "manual"; + resolved.accountIdSource = + flagged.accountIdSource ?? "manual"; } if (!resolved.accountLabel && flagged.accountLabel) { resolved.accountLabel = flagged.accountLabel; } restored.push(resolved); - console.log(`[${i + 1}/${flaggedStorage.accounts.length}] ${label}: RESTORED`); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); console.log( - `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: ERROR (${message.slice(0, 120)})`, + `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: RESTORED (Codex CLI cache)`, ); - remaining.push({ - ...flagged, - lastError: message, - }); + continue; } - } - if (restored.length > 0) { - await persistAccountPool(restored, false); - invalidateAccountManagerCache(); + const refreshResult = await queuedRefresh( + flagged.refreshToken, + ); + if (refreshResult.type !== "success") { + console.log( + `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: STILL FLAGGED (${refreshResult.message ?? refreshResult.reason ?? "refresh failed"})`, + ); + remaining.push(flagged); + continue; + } + + const resolved = resolveTokenSuccessAccount(refreshResult); + if (!resolved.accountIdOverride && flagged.accountId) { + resolved.accountIdOverride = flagged.accountId; + resolved.accountIdSource = + flagged.accountIdSource ?? "manual"; + } + if (!resolved.accountLabel && flagged.accountLabel) { + resolved.accountLabel = flagged.accountLabel; + } + restored.push(resolved); + console.log( + `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: RESTORED`, + ); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + console.log( + `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: ERROR (${message.slice(0, 120)})`, + ); + remaining.push({ + ...flagged, + lastError: message, + }); } + } - await saveFlaggedAccounts({ - version: 1, - accounts: remaining, - }); + if (restored.length > 0) { + await persistAccountPool(restored, false); + invalidateAccountManagerCache(); + } - console.log(""); - console.log(`Results: ${restored.length} restored, ${remaining.length} still flagged`); - console.log(""); - }; + await saveFlaggedAccounts({ + version: 1, + accounts: remaining, + }); - if (!explicitLoginMode) { - while (true) { - const loadedStorage = await hydrateEmails(await loadAccounts()); - const workingStorage = loadedStorage - ? { + console.log(""); + console.log( + `Results: ${restored.length} restored, ${remaining.length} still flagged`, + ); + console.log(""); + }; + + if (!explicitLoginMode) { + while (true) { + const loadedStorage = await hydrateEmails(await loadAccounts()); + const workingStorage = loadedStorage + ? { ...loadedStorage, - accounts: loadedStorage.accounts.map((account) => ({ ...account })), + accounts: loadedStorage.accounts.map((account) => ({ + ...account, + })), activeIndexByFamily: loadedStorage.activeIndexByFamily ? { ...loadedStorage.activeIndexByFamily } : {}, } - : { version: 3 as const, accounts: [], activeIndex: 0, activeIndexByFamily: {} }; - const flaggedStorage = await loadFlaggedAccounts(); + : { + version: 3 as const, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }; + const flaggedStorage = await loadFlaggedAccounts(); - if (workingStorage.accounts.length === 0 && flaggedStorage.accounts.length === 0) { - break; - } + if ( + workingStorage.accounts.length === 0 && + flaggedStorage.accounts.length === 0 + ) { + break; + } - const now = Date.now(); - const activeIndex = resolveActiveIndex(workingStorage, "codex"); - const existingAccounts = workingStorage.accounts.map((account, index) => { - let status: "active" | "ok" | "rate-limited" | "cooldown" | "disabled"; + const now = Date.now(); + const activeIndex = resolveActiveIndex(workingStorage, "codex"); + const existingAccounts = workingStorage.accounts.map( + (account, index) => { + let status: + | "active" + | "ok" + | "rate-limited" + | "cooldown" + | "disabled"; if (account.enabled === false) { status = "disabled"; } else if ( @@ -3257,7 +2927,9 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { account.coolingDownUntil > now ) { status = "cooldown"; - } else if (formatRateLimitEntry(account, now)) { + } else if ( + formatRateLimitEntry(account, now, formatWaitTime) + ) { status = "rate-limited"; } else if (index === activeIndex) { status = "active"; @@ -3273,138 +2945,166 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { lastUsed: account.lastUsed, status, isCurrentAccount: index === activeIndex, - enabled: account.enabled !== false, - }; - }); - - const menuResult = await promptLoginMode(existingAccounts, { - flaggedCount: flaggedStorage.accounts.length, - }); - - if (menuResult.mode === "cancel") { - return { - url: "", - instructions: "Authentication cancelled", - method: "auto", - callback: () => - Promise.resolve({ - type: "failed" as const, - }), + enabled: account.enabled !== false, }; - } + }, + ); - if (menuResult.mode === "check") { - await runAccountCheck(false); - continue; - } - if (menuResult.mode === "deep-check") { - await runAccountCheck(true); - continue; - } - if (menuResult.mode === "verify-flagged") { - await verifyFlaggedAccounts(); - continue; - } + const menuResult = await promptLoginMode(existingAccounts, { + flaggedCount: flaggedStorage.accounts.length, + }); - if (menuResult.mode === "manage") { - if (typeof menuResult.deleteAccountIndex === "number") { - const target = workingStorage.accounts[menuResult.deleteAccountIndex]; - if (target) { - workingStorage.accounts.splice(menuResult.deleteAccountIndex, 1); - clampActiveIndices(workingStorage); - await saveAccounts(workingStorage); - await saveFlaggedAccounts({ - version: 1, - accounts: flaggedStorage.accounts.filter( - (flagged) => flagged.refreshToken !== target.refreshToken, - ), - }); - invalidateAccountManagerCache(); - console.log(`\nDeleted ${target.email ?? `Account ${menuResult.deleteAccountIndex + 1}`}.\n`); - } - continue; - } + if (menuResult.mode === "cancel") { + return { + url: "", + instructions: "Authentication cancelled", + method: "auto", + callback: () => + Promise.resolve({ + type: "failed" as const, + }), + }; + } - if (typeof menuResult.toggleAccountIndex === "number") { - const target = workingStorage.accounts[menuResult.toggleAccountIndex]; - if (target) { - target.enabled = target.enabled === false ? true : false; - await saveAccounts(workingStorage); - invalidateAccountManagerCache(); - console.log( - `\n${target.email ?? `Account ${menuResult.toggleAccountIndex + 1}`} ${target.enabled === false ? "disabled" : "enabled"}.\n`, - ); - } - continue; - } + if (menuResult.mode === "check") { + await runAccountCheck(false); + continue; + } + if (menuResult.mode === "deep-check") { + await runAccountCheck(true); + continue; + } + if (menuResult.mode === "verify-flagged") { + await verifyFlaggedAccounts(); + continue; + } - if (typeof menuResult.refreshAccountIndex === "number") { - refreshAccountIndex = menuResult.refreshAccountIndex; - startFresh = false; - break; + if (menuResult.mode === "manage") { + if (typeof menuResult.deleteAccountIndex === "number") { + const target = + workingStorage.accounts[menuResult.deleteAccountIndex]; + if (target) { + workingStorage.accounts.splice( + menuResult.deleteAccountIndex, + 1, + ); + clampActiveIndices(workingStorage, MODEL_FAMILIES); + await saveAccounts(workingStorage); + await saveFlaggedAccounts({ + version: 1, + accounts: flaggedStorage.accounts.filter( + (flagged) => + flagged.refreshToken !== target.refreshToken, + ), + }); + invalidateAccountManagerCache(); + console.log( + `\nDeleted ${target.email ?? `Account ${menuResult.deleteAccountIndex + 1}`}.\n`, + ); } - continue; } - if (menuResult.mode === "fresh") { - startFresh = true; - if (menuResult.deleteAll) { - await clearAccounts(); - await clearFlaggedAccounts(); + if (typeof menuResult.toggleAccountIndex === "number") { + const target = + workingStorage.accounts[menuResult.toggleAccountIndex]; + if (target) { + target.enabled = target.enabled === false ? true : false; + await saveAccounts(workingStorage); invalidateAccountManagerCache(); console.log( - "\nCleared saved accounts from active storage. Recovery snapshots remain available. Starting fresh.\n", + `\n${target.email ?? `Account ${menuResult.toggleAccountIndex + 1}`} ${target.enabled === false ? "disabled" : "enabled"}.\n`, ); } + continue; + } + + if (typeof menuResult.refreshAccountIndex === "number") { + refreshAccountIndex = menuResult.refreshAccountIndex; + startFresh = false; break; } - startFresh = false; + continue; + } + + if (menuResult.mode === "fresh") { + startFresh = true; + if (menuResult.deleteAll) { + await clearAccounts(); + await clearFlaggedAccounts(); + invalidateAccountManagerCache(); + console.log( + "\nCleared saved accounts from active storage. Recovery snapshots remain available. Starting fresh.\n", + ); + } break; } - } - const latestStorage = await loadAccounts(); - const existingCount = latestStorage?.accounts.length ?? 0; - const requestedCount = Number.parseInt(inputs?.accountCount ?? "1", 10); - const normalizedRequested = Number.isFinite(requestedCount) ? requestedCount : 1; - const availableSlots = - refreshAccountIndex !== undefined - ? 1 - : startFresh - ? ACCOUNT_LIMITS.MAX_ACCOUNTS - : ACCOUNT_LIMITS.MAX_ACCOUNTS - existingCount; - - if (availableSlots <= 0) { - return { - url: "", - instructions: "Account limit reached. Remove an account or start fresh.", - method: "auto", - callback: () => - Promise.resolve({ - type: "failed" as const, - }), - }; + startFresh = false; + break; } + } - let targetCount = Math.max(1, Math.min(normalizedRequested, availableSlots)); - if (refreshAccountIndex !== undefined) { - targetCount = 1; - } - if (useManualMode) { - targetCount = 1; - } + const latestStorage = await loadAccounts(); + const existingCount = latestStorage?.accounts.length ?? 0; + const requestedCount = Number.parseInt( + inputs?.accountCount ?? "1", + 10, + ); + const normalizedRequested = Number.isFinite(requestedCount) + ? requestedCount + : 1; + const availableSlots = + refreshAccountIndex !== undefined + ? 1 + : startFresh + ? ACCOUNT_LIMITS.MAX_ACCOUNTS + : ACCOUNT_LIMITS.MAX_ACCOUNTS - existingCount; + + if (availableSlots <= 0) { + return { + url: "", + instructions: + "Account limit reached. Remove an account or start fresh.", + method: "auto", + callback: () => + Promise.resolve({ + type: "failed" as const, + }), + }; + } - if (useManualMode) { - const { pkce, state, url } = await createAuthorizationFlow(); - return buildManualOAuthFlow(pkce, url, state, async (tokens) => { + let targetCount = Math.max( + 1, + Math.min(normalizedRequested, availableSlots), + ); + if (refreshAccountIndex !== undefined) { + targetCount = 1; + } + if (useManualMode) { + targetCount = 1; + } + + if (useManualMode) { + const { pkce, state, url } = await createAuthorizationFlow(); + return buildManualOAuthFlow({ + pkce, + url, + expectedState: state, + redirectUri: REDIRECT_URI, + parseAuthorizationInput, + exchangeAuthorizationCode, + resolveTokenSuccess: resolveTokenSuccessAccount, + instructions: AUTH_LABELS.INSTRUCTIONS_MANUAL, + onSuccess: async (tokens) => { try { await persistAccountPool([tokens], startFresh); invalidateAccountManagerCache(); } catch (err) { const storagePath = getStoragePath(); - const errorCode = (err as NodeJS.ErrnoException)?.code || "UNKNOWN"; + const errorCode = + (err as NodeJS.ErrnoException)?.code || "UNKNOWN"; const hint = err instanceof StorageError ? err.hint @@ -3417,77 +3117,194 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { duration: 10000, }); } - }); - } + }, + }); + } - const explicitCountProvided = - typeof inputs?.accountCount === "string" && inputs.accountCount.trim().length > 0; - - while (accounts.length < targetCount) { - logInfo(`=== OpenAI OAuth (Account ${accounts.length + 1}) ===`); - const forceNewLogin = accounts.length > 0 || refreshAccountIndex !== undefined; - const result = await runOAuthFlow(forceNewLogin); - - let resolved: TokenSuccessWithAccount | null = null; - if (result.type === "success") { - resolved = resolveAccountSelection(result); - const email = extractAccountEmail(resolved.access, resolved.idToken); - const accountId = resolved.accountIdOverride ?? extractAccountId(resolved.access); - const label = resolved.accountLabel ?? email ?? accountId ?? "Unknown account"; - logInfo(`Authenticated as: ${label}`); - - const isDuplicate = - findMatchingAccountIndex( - accounts.map((account) => ({ - accountId: - account.accountIdOverride ?? extractAccountId(account.access), - email: sanitizeEmail( - extractAccountEmail(account.access, account.idToken), - ), - refreshToken: account.refresh, - })), - { - accountId, - email: sanitizeEmail(email), - refreshToken: resolved.refresh, - }, - { - allowUniqueAccountIdFallbackWithoutEmail: true, - }, - ) !== undefined; + const explicitCountProvided = + typeof inputs?.accountCount === "string" && + inputs.accountCount.trim().length > 0; + + while (accounts.length < targetCount) { + logInfo(`=== OpenAI OAuth (Account ${accounts.length + 1}) ===`); + const forceNewLogin = + accounts.length > 0 || refreshAccountIndex !== undefined; + const result = await runOAuthFlow(forceNewLogin); + + let resolved: TokenSuccessWithAccount | null = null; + if (result.type === "success") { + resolved = resolveTokenSuccessAccount(result); + const email = extractAccountEmail( + resolved.access, + resolved.idToken, + ); + const accountId = + resolved.accountIdOverride ?? + extractAccountId(resolved.access); + const label = + resolved.accountLabel ?? + email ?? + accountId ?? + "Unknown account"; + logInfo(`Authenticated as: ${label}`); + + const isDuplicate = + findMatchingAccountIndex( + accounts.map((account) => ({ + accountId: + account.accountIdOverride ?? + extractAccountId(account.access), + email: sanitizeEmail( + extractAccountEmail(account.access, account.idToken), + ), + refreshToken: account.refresh, + })), + { + accountId, + email: sanitizeEmail(email), + refreshToken: resolved.refresh, + }, + { + allowUniqueAccountIdFallbackWithoutEmail: true, + }, + ) !== undefined; - if (isDuplicate) { - logWarn(`WARNING: duplicate account login detected (${label}). Existing entry will be updated.`); - } + if (isDuplicate) { + logWarn( + `WARNING: duplicate account login detected (${label}). Existing entry will be updated.`, + ); } + } - if (result.type === "failed") { - if (accounts.length === 0) { - return { - url: "", - instructions: "Authentication failed.", - method: "auto", - callback: () => Promise.resolve(result), - }; - } - logWarn(`[${PLUGIN_NAME}] Skipping failed account ${accounts.length + 1}`); - break; + if (result.type === "failed") { + if (accounts.length === 0) { + return { + url: "", + instructions: "Authentication failed.", + method: "auto", + callback: () => Promise.resolve(result), + }; } + logWarn( + `[${PLUGIN_NAME}] Skipping failed account ${accounts.length + 1}`, + ); + break; + } + + if (!resolved) { + continue; + } + + accounts.push(resolved); + await showToast( + `Account ${accounts.length} authenticated`, + "success", + ); + + try { + const isFirstAccount = accounts.length === 1; + await persistAccountPool( + [resolved], + isFirstAccount && startFresh, + ); + invalidateAccountManagerCache(); + } catch (err) { + const storagePath = getStoragePath(); + const errorCode = + (err as NodeJS.ErrnoException)?.code || "UNKNOWN"; + const hint = + err instanceof StorageError + ? err.hint + : formatStorageErrorHint(err, storagePath); + logError( + `[${PLUGIN_NAME}] Failed to persist account: [${errorCode}] ${(err as Error)?.message ?? String(err)}`, + ); + await showToast(hint, "error", { + title: "Account Persistence Failed", + duration: 10000, + }); + } + + if (accounts.length >= ACCOUNT_LIMITS.MAX_ACCOUNTS) { + break; + } - if (!resolved) { + if ( + !explicitCountProvided && + refreshAccountIndex === undefined && + accounts.length < availableSlots && + accounts.length >= targetCount + ) { + const addMore = await promptAddAnotherAccount(accounts.length); + if (addMore) { + targetCount = Math.min(targetCount + 1, availableSlots); continue; } + break; + } + } + + const primary = accounts[0]; + if (!primary) { + return { + url: "", + instructions: "Authentication cancelled", + method: "auto", + callback: () => + Promise.resolve({ + type: "failed" as const, + }), + }; + } - accounts.push(resolved); - await showToast(`Account ${accounts.length} authenticated`, "success"); + let actualAccountCount = accounts.length; + try { + const finalStorage = await loadAccounts(); + if (finalStorage) { + actualAccountCount = finalStorage.accounts.length; + } + } catch (err) { + logWarn( + `[${PLUGIN_NAME}] Failed to load final account count: ${(err as Error)?.message ?? String(err)}`, + ); + } + + return { + url: "", + instructions: `Multi-account setup complete (${actualAccountCount} account(s)).`, + method: "auto", + callback: () => Promise.resolve(primary), + }; + }, + }, + { + label: AUTH_LABELS.OAUTH_MANUAL, + type: "oauth" as const, + authorize: async () => { + // Initialize storage path for manual OAuth flow + // Must happen BEFORE persistAccountPool to ensure correct storage location + const manualPluginConfig = loadPluginConfig(); + applyUiRuntimeFromConfig(manualPluginConfig, setUiRuntimeOptions); + applyAccountStorageScope(manualPluginConfig); + + const { pkce, state, url } = await createAuthorizationFlow(); + return buildManualOAuthFlow({ + pkce, + url, + expectedState: state, + redirectUri: REDIRECT_URI, + parseAuthorizationInput, + exchangeAuthorizationCode, + resolveTokenSuccess: resolveTokenSuccessAccount, + instructions: AUTH_LABELS.INSTRUCTIONS_MANUAL, + onSuccess: async (tokens) => { try { - const isFirstAccount = accounts.length === 1; - await persistAccountPool([resolved], isFirstAccount && startFresh); - invalidateAccountManagerCache(); + await persistAccountPool([tokens], false); } catch (err) { const storagePath = getStoragePath(); - const errorCode = (err as NodeJS.ErrnoException)?.code || "UNKNOWN"; + const errorCode = + (err as NodeJS.ErrnoException)?.code || "UNKNOWN"; const hint = err instanceof StorageError ? err.hint @@ -3500,106 +3317,28 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { duration: 10000, }); } - - if (accounts.length >= ACCOUNT_LIMITS.MAX_ACCOUNTS) { - break; - } - - if ( - !explicitCountProvided && - refreshAccountIndex === undefined && - accounts.length < availableSlots && - accounts.length >= targetCount - ) { - const addMore = await promptAddAnotherAccount(accounts.length); - if (addMore) { - targetCount = Math.min(targetCount + 1, availableSlots); - continue; - } - break; - } - } - - const primary = accounts[0]; - if (!primary) { - return { - url: "", - instructions: "Authentication cancelled", - method: "auto", - callback: () => - Promise.resolve({ - type: "failed" as const, - }), - }; - } - - let actualAccountCount = accounts.length; - try { - const finalStorage = await loadAccounts(); - if (finalStorage) { - actualAccountCount = finalStorage.accounts.length; - } - } catch (err) { - logWarn( - `[${PLUGIN_NAME}] Failed to load final account count: ${(err as Error)?.message ?? String(err)}`, - ); - } - - return { - url: "", - instructions: `Multi-account setup complete (${actualAccountCount} account(s)).`, - method: "auto", - callback: () => Promise.resolve(primary), - }; - }, + }, + }); }, - - { - label: AUTH_LABELS.OAUTH_MANUAL, - type: "oauth" as const, - authorize: async () => { - // Initialize storage path for manual OAuth flow - // Must happen BEFORE persistAccountPool to ensure correct storage location - const manualPluginConfig = loadPluginConfig(); - applyUiRuntimeFromConfig(manualPluginConfig); - applyAccountStorageScope(manualPluginConfig); - - const { pkce, state, url } = await createAuthorizationFlow(); - return buildManualOAuthFlow(pkce, url, state, async (tokens) => { - try { - await persistAccountPool([tokens], false); - } catch (err) { - const storagePath = getStoragePath(); - const errorCode = (err as NodeJS.ErrnoException)?.code || "UNKNOWN"; - const hint = err instanceof StorageError ? err.hint : formatStorageErrorHint(err, storagePath); - logError(`[${PLUGIN_NAME}] Failed to persist account: [${errorCode}] ${(err as Error)?.message ?? String(err)}`); - await showToast( - hint, - "error", - { title: "Account Persistence Failed", duration: 10000 }, - ); - } - }); - }, - }, - ], - }, - tool: { - edit: createHashlineEditTool(), - // Legacy runtime v1.2.x exposes apply_patch (not edit) to the model. - // Register the same hashline-capable implementation under both names. - apply_patch: createHashlineEditTool(), - hashline_read: createHashlineReadTool(), - "codex-list": tool({ - description: - "List all Codex OAuth accounts and the current active index.", - args: {}, - async execute() { + }, + ], + }, + tool: { + edit: createHashlineEditTool(), + // Legacy runtime v1.2.x exposes apply_patch (not edit) to the model. + // Register the same hashline-capable implementation under both names. + apply_patch: createHashlineEditTool(), + hashline_read: createHashlineReadTool(), + "codex-list": tool({ + description: + "List all Codex OAuth accounts and the current active index.", + args: {}, + async execute() { const ui = resolveUiRuntime(); - const storage = await loadAccounts(); - const storePath = getStoragePath(); + const storage = await loadAccounts(); + const storePath = getStoragePath(); - if (!storage || storage.accounts.length === 0) { + if (!storage || storage.accounts.length === 0) { if (ui.v2Enabled) { return [ ...formatUiHeader(ui, "Codex accounts"), @@ -3609,15 +3348,15 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { formatUiKeyValue(ui, "Storage", storePath, "muted"), ].join("\n"); } - return [ - "No Codex accounts configured.", - "", - "Add accounts:", - " codex login", - "", - `Storage: ${storePath}`, - ].join("\n"); - } + return [ + "No Codex accounts configured.", + "", + "Add accounts:", + " codex login", + "", + `Storage: ${storePath}`, + ].join("\n"); + } const now = Date.now(); const activeIndex = resolveActiveIndex(storage, "codex"); @@ -3633,10 +3372,17 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { storage.accounts.forEach((account, index) => { const label = formatAccountLabel(account, index); const badges: string[] = []; - if (index === activeIndex) badges.push(formatUiBadge(ui, "current", "accent")); - if (account.enabled === false) badges.push(formatUiBadge(ui, "disabled", "danger")); - const rateLimit = formatRateLimitEntry(account, now); - if (rateLimit) badges.push(formatUiBadge(ui, "rate-limited", "warning")); + if (index === activeIndex) + badges.push(formatUiBadge(ui, "current", "accent")); + if (account.enabled === false) + badges.push(formatUiBadge(ui, "disabled", "danger")); + const rateLimit = formatRateLimitEntry( + account, + now, + formatWaitTime, + ); + if (rateLimit) + badges.push(formatUiBadge(ui, "rate-limited", "warning")); if ( typeof account.coolingDownUntil === "number" && account.coolingDownUntil > now @@ -3647,21 +3393,30 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { badges.push(formatUiBadge(ui, "ok", "success")); } - lines.push(formatUiItem(ui, `${index + 1}. ${label} ${badges.join(" ")}`.trim())); + lines.push( + formatUiItem( + ui, + `${index + 1}. ${label} ${badges.join(" ")}`.trim(), + ), + ); if (rateLimit) { - lines.push(` ${paintUiText(ui, `rate limit: ${rateLimit}`, "muted")}`); + lines.push( + ` ${paintUiText(ui, `rate limit: ${rateLimit}`, "muted")}`, + ); } }); lines.push(""); lines.push(...formatUiSection(ui, "Commands")); lines.push(formatUiItem(ui, "Add account: codex login", "accent")); - lines.push(formatUiItem(ui, "Switch account: codex-switch ")); + lines.push( + formatUiItem(ui, "Switch account: codex-switch "), + ); lines.push(formatUiItem(ui, "Detailed status: codex-status")); lines.push(formatUiItem(ui, "Health check: codex-health")); return lines.join("\n"); } - + const listTableOptions: TableOptions = { columns: [ { header: "#", width: 3 }, @@ -3669,56 +3424,63 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { { header: "Status", width: 20 }, ], }; - + const lines: string[] = [ `Codex Accounts (${storage.accounts.length}):`, "", ...buildTableHeader(listTableOptions), ]; - storage.accounts.forEach((account, index) => { - const label = formatAccountLabel(account, index); - const statuses: string[] = []; - const rateLimit = formatRateLimitEntry( - account, - now, - ); - if (index === activeIndex) statuses.push("active"); - if (rateLimit) statuses.push("rate-limited"); - if ( - typeof account.coolingDownUntil === - "number" && - account.coolingDownUntil > now - ) { - statuses.push("cooldown"); - } - const statusText = statuses.length > 0 ? statuses.join(", ") : "ok"; - lines.push(buildTableRow([String(index + 1), label, statusText], listTableOptions)); - }); - - lines.push(""); - lines.push(`Storage: ${storePath}`); - lines.push(""); - lines.push("Commands:"); - lines.push(" - Add account: codex login"); - lines.push(" - Switch account: codex-switch"); - lines.push(" - Status details: codex-status"); - lines.push(" - Health check: codex-health"); - - return lines.join("\n"); - }, - }), - "codex-switch": tool({ - description: "Switch active Codex account by index (1-based).", - args: { - index: tool.schema.number().describe( - "Account number to switch to (1-based, e.g., 1 for first account)", - ), - }, - async execute({ index }) { + storage.accounts.forEach((account, index) => { + const label = formatAccountLabel(account, index); + const statuses: string[] = []; + const rateLimit = formatRateLimitEntry( + account, + now, + formatWaitTime, + ); + if (index === activeIndex) statuses.push("active"); + if (rateLimit) statuses.push("rate-limited"); + if ( + typeof account.coolingDownUntil === "number" && + account.coolingDownUntil > now + ) { + statuses.push("cooldown"); + } + const statusText = statuses.length > 0 ? statuses.join(", ") : "ok"; + lines.push( + buildTableRow( + [String(index + 1), label, statusText], + listTableOptions, + ), + ); + }); + + lines.push(""); + lines.push(`Storage: ${storePath}`); + lines.push(""); + lines.push("Commands:"); + lines.push(" - Add account: codex login"); + lines.push(" - Switch account: codex-switch"); + lines.push(" - Status details: codex-status"); + lines.push(" - Health check: codex-health"); + + return lines.join("\n"); + }, + }), + "codex-switch": tool({ + description: "Switch active Codex account by index (1-based).", + args: { + index: tool.schema + .number() + .describe( + "Account number to switch to (1-based, e.g., 1 for first account)", + ), + }, + async execute({ index }) { const ui = resolveUiRuntime(); - const storage = await loadAccounts(); - if (!storage || storage.accounts.length === 0) { + const storage = await loadAccounts(); + if (!storage || storage.accounts.length === 0) { if (ui.v2Enabled) { return [ ...formatUiHeader(ui, "Switch account"), @@ -3727,48 +3489,63 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { formatUiItem(ui, "Run: codex login", "accent"), ].join("\n"); } - return "No Codex accounts configured. Run: codex login"; - } - - const targetIndex = Math.floor((index ?? 0) - 1); - if ( - !Number.isFinite(targetIndex) || - targetIndex < 0 || - targetIndex >= storage.accounts.length - ) { + return "No Codex accounts configured. Run: codex login"; + } + + const targetIndex = Math.floor((index ?? 0) - 1); + if ( + !Number.isFinite(targetIndex) || + targetIndex < 0 || + targetIndex >= storage.accounts.length + ) { if (ui.v2Enabled) { return [ ...formatUiHeader(ui, "Switch account"), "", formatUiItem(ui, `Invalid account number: ${index}`, "danger"), - formatUiKeyValue(ui, "Valid range", `1-${storage.accounts.length}`, "muted"), + formatUiKeyValue( + ui, + "Valid range", + `1-${storage.accounts.length}`, + "muted", + ), ].join("\n"); } - return `Invalid account number: ${index}\n\nValid range: 1-${storage.accounts.length}`; - } + return `Invalid account number: ${index}\n\nValid range: 1-${storage.accounts.length}`; + } - const now = Date.now(); - const account = storage.accounts[targetIndex]; - if (account) { - account.lastUsed = now; - account.lastSwitchReason = "rotation"; - } + const now = Date.now(); + const account = storage.accounts[targetIndex]; + if (account) { + account.lastUsed = now; + account.lastSwitchReason = "rotation"; + } storage.activeIndex = targetIndex; storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; for (const family of MODEL_FAMILIES) { - storage.activeIndexByFamily[family] = targetIndex; + storage.activeIndexByFamily[family] = targetIndex; } try { await saveAccounts(storage); } catch (saveError) { - logWarn("Failed to save account switch", { error: String(saveError) }); + logWarn("Failed to save account switch", { + error: String(saveError), + }); if (ui.v2Enabled) { return [ ...formatUiHeader(ui, "Switch account"), "", - formatUiItem(ui, `Switched to ${formatAccountLabel(account, targetIndex)}`, "warning"), - formatUiItem(ui, "Failed to persist change. It may be lost on restart.", "danger"), + formatUiItem( + ui, + `Switched to ${formatAccountLabel(account, targetIndex)}`, + "warning", + ), + formatUiItem( + ui, + "Failed to persist change. It may be lost on restart.", + "danger", + ), ].join("\n"); } return `Switched to ${formatAccountLabel(account, targetIndex)} but failed to persist. Changes may be lost on restart.`; @@ -3778,244 +3555,398 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { await reloadAccountManagerFromDisk(); } - const label = formatAccountLabel(account, targetIndex); - if (ui.v2Enabled) { - return [ - ...formatUiHeader(ui, "Switch account"), - "", - formatUiItem(ui, `${getStatusMarker(ui, "ok")} Switched to ${label}`, "success"), - ].join("\n"); - } - return `Switched to account: ${label}`; - }, - }), - "codex-status": tool({ - description: "Show detailed status of Codex accounts and rate limits.", - args: {}, - async execute() { - const ui = resolveUiRuntime(); - const storage = await loadAccounts(); - if (!storage || storage.accounts.length === 0) { - if (ui.v2Enabled) { - return [ - ...formatUiHeader(ui, "Account status"), - "", - formatUiItem(ui, "No accounts configured.", "warning"), - formatUiItem(ui, "Run: codex login", "accent"), - ].join("\n"); + const label = formatAccountLabel(account, targetIndex); + if (ui.v2Enabled) { + return [ + ...formatUiHeader(ui, "Switch account"), + "", + formatUiItem( + ui, + `${getStatusMarker(ui, "ok")} Switched to ${label}`, + "success", + ), + ].join("\n"); + } + return `Switched to account: ${label}`; + }, + }), + "codex-status": tool({ + description: "Show detailed status of Codex accounts and rate limits.", + args: {}, + async execute() { + const ui = resolveUiRuntime(); + const storage = await loadAccounts(); + if (!storage || storage.accounts.length === 0) { + if (ui.v2Enabled) { + return [ + ...formatUiHeader(ui, "Account status"), + "", + formatUiItem(ui, "No accounts configured.", "warning"), + formatUiItem(ui, "Run: codex login", "accent"), + ].join("\n"); + } + return "No Codex accounts configured. Run: codex login"; + } + + const now = Date.now(); + const activeIndex = resolveActiveIndex(storage, "codex"); + if (ui.v2Enabled) { + const lines: string[] = [ + ...formatUiHeader(ui, "Account status"), + formatUiKeyValue(ui, "Total", String(storage.accounts.length)), + "", + ...formatUiSection(ui, "Accounts"), + ]; + + storage.accounts.forEach((account, index) => { + const label = formatAccountLabel(account, index); + const badges: string[] = []; + if (index === activeIndex) + badges.push(formatUiBadge(ui, "active", "accent")); + if (account.enabled === false) + badges.push(formatUiBadge(ui, "disabled", "danger")); + const rateLimit = + formatRateLimitEntry(account, now, formatWaitTime) ?? "none"; + const cooldown = formatCooldown(account, now) ?? "none"; + if (rateLimit !== "none") + badges.push(formatUiBadge(ui, "rate-limited", "warning")); + if (cooldown !== "none") + badges.push(formatUiBadge(ui, "cooldown", "warning")); + if (badges.length === 0) + badges.push(formatUiBadge(ui, "ok", "success")); + + lines.push( + formatUiItem( + ui, + `${index + 1}. ${label} ${badges.join(" ")}`.trim(), + ), + ); + lines.push( + ` ${formatUiKeyValue(ui, "rate limit", rateLimit, rateLimit === "none" ? "muted" : "warning")}`, + ); + lines.push( + ` ${formatUiKeyValue(ui, "cooldown", cooldown, cooldown === "none" ? "muted" : "warning")}`, + ); + }); + + lines.push(""); + lines.push(...formatUiSection(ui, "Active index by model family")); + for (const family of MODEL_FAMILIES) { + const idx = storage.activeIndexByFamily?.[family]; + const familyIndexLabel = + typeof idx === "number" && Number.isFinite(idx) + ? String(idx + 1) + : "-"; + lines.push(formatUiItem(ui, `${family}: ${familyIndexLabel}`)); } - return "No Codex accounts configured. Run: codex login"; + + lines.push(""); + lines.push( + ...formatUiSection( + ui, + "Rate limits by model family (per account)", + ), + ); + storage.accounts.forEach((account, index) => { + const statuses = MODEL_FAMILIES.map((family) => { + const resetAt = getRateLimitResetTimeForFamily( + account, + now, + family, + ); + if (typeof resetAt !== "number") return `${family}=ok`; + return `${family}=${formatWaitTime(resetAt - now)}`; + }); + lines.push( + formatUiItem( + ui, + `Account ${index + 1}: ${statuses.join(" | ")}`, + ), + ); + }); + + return lines.join("\n"); } - const now = Date.now(); - const activeIndex = resolveActiveIndex(storage, "codex"); - if (ui.v2Enabled) { + const statusTableOptions: TableOptions = { + columns: [ + { header: "#", width: 3 }, + { header: "Label", width: 42 }, + { header: "Active", width: 6 }, + { header: "Rate Limit", width: 16 }, + { header: "Cooldown", width: 16 }, + { header: "Last Used", width: 16 }, + ], + }; + const lines: string[] = [ - ...formatUiHeader(ui, "Account status"), - formatUiKeyValue(ui, "Total", String(storage.accounts.length)), + `Account Status (${storage.accounts.length} total):`, "", - ...formatUiSection(ui, "Accounts"), + ...buildTableHeader(statusTableOptions), ]; storage.accounts.forEach((account, index) => { const label = formatAccountLabel(account, index); - const badges: string[] = []; - if (index === activeIndex) badges.push(formatUiBadge(ui, "active", "accent")); - if (account.enabled === false) badges.push(formatUiBadge(ui, "disabled", "danger")); - const rateLimit = formatRateLimitEntry(account, now) ?? "none"; - const cooldown = formatCooldown(account, now) ?? "none"; - if (rateLimit !== "none") badges.push(formatUiBadge(ui, "rate-limited", "warning")); - if (cooldown !== "none") badges.push(formatUiBadge(ui, "cooldown", "warning")); - if (badges.length === 0) badges.push(formatUiBadge(ui, "ok", "success")); - - lines.push(formatUiItem(ui, `${index + 1}. ${label} ${badges.join(" ")}`.trim())); - lines.push(` ${formatUiKeyValue(ui, "rate limit", rateLimit, rateLimit === "none" ? "muted" : "warning")}`); - lines.push(` ${formatUiKeyValue(ui, "cooldown", cooldown, cooldown === "none" ? "muted" : "warning")}`); + const active = index === activeIndex ? "Yes" : "No"; + const rateLimit = + formatRateLimitEntry(account, now, formatWaitTime) ?? "None"; + const cooldown = formatCooldown(account, now) ?? "No"; + const lastUsed = + typeof account.lastUsed === "number" && account.lastUsed > 0 + ? `${formatWaitTime(now - account.lastUsed)} ago` + : "-"; + + lines.push( + buildTableRow( + [ + String(index + 1), + label, + active, + rateLimit, + cooldown, + lastUsed, + ], + statusTableOptions, + ), + ); }); lines.push(""); - lines.push(...formatUiSection(ui, "Active index by model family")); + lines.push("Active index by model family:"); for (const family of MODEL_FAMILIES) { const idx = storage.activeIndexByFamily?.[family]; const familyIndexLabel = - typeof idx === "number" && Number.isFinite(idx) ? String(idx + 1) : "-"; - lines.push(formatUiItem(ui, `${family}: ${familyIndexLabel}`)); + typeof idx === "number" && Number.isFinite(idx) + ? String(idx + 1) + : "-"; + lines.push(` ${family}: ${familyIndexLabel}`); } lines.push(""); - lines.push(...formatUiSection(ui, "Rate limits by model family (per account)")); + lines.push("Rate limits by model family (per account):"); storage.accounts.forEach((account, index) => { const statuses = MODEL_FAMILIES.map((family) => { - const resetAt = getRateLimitResetTimeForFamily(account, now, family); + const resetAt = getRateLimitResetTimeForFamily( + account, + now, + family, + ); if (typeof resetAt !== "number") return `${family}=ok`; return `${family}=${formatWaitTime(resetAt - now)}`; }); - lines.push(formatUiItem(ui, `Account ${index + 1}: ${statuses.join(" | ")}`)); + lines.push(` Account ${index + 1}: ${statuses.join(" | ")}`); }); return lines.join("\n"); - } - - const statusTableOptions: TableOptions = { - columns: [ - { header: "#", width: 3 }, - { header: "Label", width: 42 }, - { header: "Active", width: 6 }, - { header: "Rate Limit", width: 16 }, - { header: "Cooldown", width: 16 }, - { header: "Last Used", width: 16 }, - ], - }; - - const lines: string[] = [ - `Account Status (${storage.accounts.length} total):`, - "", - ...buildTableHeader(statusTableOptions), - ]; - - storage.accounts.forEach((account, index) => { - const label = formatAccountLabel(account, index); - const active = index === activeIndex ? "Yes" : "No"; - const rateLimit = formatRateLimitEntry(account, now) ?? "None"; - const cooldown = formatCooldown(account, now) ?? "No"; - const lastUsed = - typeof account.lastUsed === "number" && account.lastUsed > 0 - ? `${formatWaitTime(now - account.lastUsed)} ago` - : "-"; - - lines.push(buildTableRow([String(index + 1), label, active, rateLimit, cooldown, lastUsed], statusTableOptions)); - }); - - lines.push(""); - lines.push("Active index by model family:"); - for (const family of MODEL_FAMILIES) { - const idx = storage.activeIndexByFamily?.[family]; - const familyIndexLabel = - typeof idx === "number" && Number.isFinite(idx) ? String(idx + 1) : "-"; - lines.push(` ${family}: ${familyIndexLabel}`); - } - - lines.push(""); - lines.push("Rate limits by model family (per account):"); - storage.accounts.forEach((account, index) => { - const statuses = MODEL_FAMILIES.map((family) => { - const resetAt = getRateLimitResetTimeForFamily(account, now, family); - if (typeof resetAt !== "number") return `${family}=ok`; - return `${family}=${formatWaitTime(resetAt - now)}`; - }); - lines.push(` Account ${index + 1}: ${statuses.join(" | ")}`); - }); + }, + }), + ...(exposeAdvancedCodexTools + ? { + "codex-metrics": tool({ + description: + "Show runtime request metrics for this plugin process.", + args: {}, + execute() { + const ui = resolveUiRuntime(); + const now = Date.now(); + const uptimeMs = Math.max(0, now - runtimeMetrics.startedAt); + const total = runtimeMetrics.totalRequests; + const successful = runtimeMetrics.successfulRequests; + const successRate = + total > 0 ? ((successful / total) * 100).toFixed(1) : "0.0"; + const avgLatencyMs = + successful > 0 + ? Math.round( + runtimeMetrics.cumulativeLatencyMs / successful, + ) + : 0; + const liveSyncSnapshot = liveAccountSync?.getSnapshot(); + const guardianStats = refreshGuardian?.getStats(); + const sessionAffinityEntries = + sessionAffinityStore?.size() ?? 0; + const lastRequest = + runtimeMetrics.lastRequestAt !== null + ? `${formatWaitTime(now - runtimeMetrics.lastRequestAt)} ago` + : "never"; + + const lines = [ + "Codex Plugin Metrics:", + "", + `Uptime: ${formatWaitTime(uptimeMs)}`, + `Total upstream requests: ${total}`, + `Successful responses: ${successful}`, + `Failed responses: ${runtimeMetrics.failedRequests}`, + `Success rate: ${successRate}%`, + `Average successful latency: ${avgLatencyMs}ms`, + `Rate-limited responses: ${runtimeMetrics.rateLimitedResponses}`, + `Server errors (5xx): ${runtimeMetrics.serverErrors}`, + `Network errors: ${runtimeMetrics.networkErrors}`, + `User aborts: ${runtimeMetrics.userAborts}`, + `Auth refresh failures: ${runtimeMetrics.authRefreshFailures}`, + `Account rotations: ${runtimeMetrics.accountRotations}`, + `Same-account retries: ${runtimeMetrics.sameAccountRetries}`, + `Stream failover attempts: ${runtimeMetrics.streamFailoverAttempts}`, + `Stream failover recoveries: ${runtimeMetrics.streamFailoverRecoveries}`, + `Stream failover cross-account recoveries: ${runtimeMetrics.streamFailoverCrossAccountRecoveries}`, + `Empty-response retries: ${runtimeMetrics.emptyResponseRetries}`, + `Session affinity entries: ${sessionAffinityEntries}`, + `Live sync: ${liveSyncSnapshot?.running ? "on" : "off"} (${liveSyncSnapshot?.reloadCount ?? 0} reloads)`, + `Refresh guardian: ${guardianStats ? "on" : "off"} (${guardianStats?.refreshed ?? 0} refreshed, ${guardianStats?.failed ?? 0} failed)`, + `Last upstream request: ${lastRequest}`, + ]; - return lines.join("\n"); - }, - }), - ...(exposeAdvancedCodexTools ? { - "codex-metrics": tool({ - description: "Show runtime request metrics for this plugin process.", - args: {}, - execute() { - const ui = resolveUiRuntime(); - const now = Date.now(); - const uptimeMs = Math.max(0, now - runtimeMetrics.startedAt); - const total = runtimeMetrics.totalRequests; - const successful = runtimeMetrics.successfulRequests; - const successRate = total > 0 ? ((successful / total) * 100).toFixed(1) : "0.0"; - const avgLatencyMs = - successful > 0 - ? Math.round(runtimeMetrics.cumulativeLatencyMs / successful) - : 0; - const liveSyncSnapshot = liveAccountSync?.getSnapshot(); - const guardianStats = refreshGuardian?.getStats(); - const sessionAffinityEntries = sessionAffinityStore?.size() ?? 0; - const lastRequest = - runtimeMetrics.lastRequestAt !== null - ? `${formatWaitTime(now - runtimeMetrics.lastRequestAt)} ago` - : "never"; - - const lines = [ - "Codex Plugin Metrics:", - "", - `Uptime: ${formatWaitTime(uptimeMs)}`, - `Total upstream requests: ${total}`, - `Successful responses: ${successful}`, - `Failed responses: ${runtimeMetrics.failedRequests}`, - `Success rate: ${successRate}%`, - `Average successful latency: ${avgLatencyMs}ms`, - `Rate-limited responses: ${runtimeMetrics.rateLimitedResponses}`, - `Server errors (5xx): ${runtimeMetrics.serverErrors}`, - `Network errors: ${runtimeMetrics.networkErrors}`, - `User aborts: ${runtimeMetrics.userAborts}`, - `Auth refresh failures: ${runtimeMetrics.authRefreshFailures}`, - `Account rotations: ${runtimeMetrics.accountRotations}`, - `Same-account retries: ${runtimeMetrics.sameAccountRetries}`, - `Stream failover attempts: ${runtimeMetrics.streamFailoverAttempts}`, - `Stream failover recoveries: ${runtimeMetrics.streamFailoverRecoveries}`, - `Stream failover cross-account recoveries: ${runtimeMetrics.streamFailoverCrossAccountRecoveries}`, - `Empty-response retries: ${runtimeMetrics.emptyResponseRetries}`, - `Session affinity entries: ${sessionAffinityEntries}`, - `Live sync: ${liveSyncSnapshot?.running ? "on" : "off"} (${liveSyncSnapshot?.reloadCount ?? 0} reloads)`, - `Refresh guardian: ${guardianStats ? "on" : "off"} (${guardianStats?.refreshed ?? 0} refreshed, ${guardianStats?.failed ?? 0} failed)`, - `Last upstream request: ${lastRequest}`, - ]; + if (runtimeMetrics.lastError) { + lines.push(`Last error: ${runtimeMetrics.lastError}`); + } - if (runtimeMetrics.lastError) { - lines.push(`Last error: ${runtimeMetrics.lastError}`); - } + if (ui.v2Enabled) { + const styled: string[] = [ + ...formatUiHeader(ui, "Codex plugin metrics"), + formatUiKeyValue(ui, "Uptime", formatWaitTime(uptimeMs)), + formatUiKeyValue( + ui, + "Total upstream requests", + String(total), + ), + formatUiKeyValue( + ui, + "Successful responses", + String(successful), + "success", + ), + formatUiKeyValue( + ui, + "Failed responses", + String(runtimeMetrics.failedRequests), + "danger", + ), + formatUiKeyValue( + ui, + "Success rate", + `${successRate}%`, + "accent", + ), + formatUiKeyValue( + ui, + "Average successful latency", + `${avgLatencyMs}ms`, + ), + formatUiKeyValue( + ui, + "Rate-limited responses", + String(runtimeMetrics.rateLimitedResponses), + "warning", + ), + formatUiKeyValue( + ui, + "Server errors (5xx)", + String(runtimeMetrics.serverErrors), + "danger", + ), + formatUiKeyValue( + ui, + "Network errors", + String(runtimeMetrics.networkErrors), + "danger", + ), + formatUiKeyValue( + ui, + "User aborts", + String(runtimeMetrics.userAborts), + "muted", + ), + formatUiKeyValue( + ui, + "Auth refresh failures", + String(runtimeMetrics.authRefreshFailures), + "warning", + ), + formatUiKeyValue( + ui, + "Account rotations", + String(runtimeMetrics.accountRotations), + "accent", + ), + formatUiKeyValue( + ui, + "Same-account retries", + String(runtimeMetrics.sameAccountRetries), + "warning", + ), + formatUiKeyValue( + ui, + "Stream failover attempts", + String(runtimeMetrics.streamFailoverAttempts), + "muted", + ), + formatUiKeyValue( + ui, + "Stream failover recoveries", + String(runtimeMetrics.streamFailoverRecoveries), + "success", + ), + formatUiKeyValue( + ui, + "Stream failover cross-account recoveries", + String( + runtimeMetrics.streamFailoverCrossAccountRecoveries, + ), + "accent", + ), + formatUiKeyValue( + ui, + "Empty-response retries", + String(runtimeMetrics.emptyResponseRetries), + "warning", + ), + formatUiKeyValue( + ui, + "Session affinity entries", + String(sessionAffinityEntries), + "muted", + ), + formatUiKeyValue( + ui, + "Live sync", + `${liveSyncSnapshot?.running ? "on" : "off"} (${liveSyncSnapshot?.reloadCount ?? 0} reloads)`, + liveSyncSnapshot?.running ? "success" : "muted", + ), + formatUiKeyValue( + ui, + "Refresh guardian", + guardianStats + ? `on (${guardianStats.refreshed} refreshed, ${guardianStats.failed} failed)` + : "off", + guardianStats ? "success" : "muted", + ), + formatUiKeyValue( + ui, + "Last upstream request", + lastRequest, + "muted", + ), + ]; + if (runtimeMetrics.lastError) { + styled.push( + formatUiKeyValue( + ui, + "Last error", + runtimeMetrics.lastError, + "danger", + ), + ); + } + return Promise.resolve(styled.join("\n")); + } - if (ui.v2Enabled) { - const styled: string[] = [ - ...formatUiHeader(ui, "Codex plugin metrics"), - formatUiKeyValue(ui, "Uptime", formatWaitTime(uptimeMs)), - formatUiKeyValue(ui, "Total upstream requests", String(total)), - formatUiKeyValue(ui, "Successful responses", String(successful), "success"), - formatUiKeyValue(ui, "Failed responses", String(runtimeMetrics.failedRequests), "danger"), - formatUiKeyValue(ui, "Success rate", `${successRate}%`, "accent"), - formatUiKeyValue(ui, "Average successful latency", `${avgLatencyMs}ms`), - formatUiKeyValue(ui, "Rate-limited responses", String(runtimeMetrics.rateLimitedResponses), "warning"), - formatUiKeyValue(ui, "Server errors (5xx)", String(runtimeMetrics.serverErrors), "danger"), - formatUiKeyValue(ui, "Network errors", String(runtimeMetrics.networkErrors), "danger"), - formatUiKeyValue(ui, "User aborts", String(runtimeMetrics.userAborts), "muted"), - formatUiKeyValue(ui, "Auth refresh failures", String(runtimeMetrics.authRefreshFailures), "warning"), - formatUiKeyValue(ui, "Account rotations", String(runtimeMetrics.accountRotations), "accent"), - formatUiKeyValue(ui, "Same-account retries", String(runtimeMetrics.sameAccountRetries), "warning"), - formatUiKeyValue(ui, "Stream failover attempts", String(runtimeMetrics.streamFailoverAttempts), "muted"), - formatUiKeyValue(ui, "Stream failover recoveries", String(runtimeMetrics.streamFailoverRecoveries), "success"), - formatUiKeyValue( - ui, - "Stream failover cross-account recoveries", - String(runtimeMetrics.streamFailoverCrossAccountRecoveries), - "accent", - ), - formatUiKeyValue(ui, "Empty-response retries", String(runtimeMetrics.emptyResponseRetries), "warning"), - formatUiKeyValue(ui, "Session affinity entries", String(sessionAffinityEntries), "muted"), - formatUiKeyValue( - ui, - "Live sync", - `${liveSyncSnapshot?.running ? "on" : "off"} (${liveSyncSnapshot?.reloadCount ?? 0} reloads)`, - liveSyncSnapshot?.running ? "success" : "muted", - ), - formatUiKeyValue( - ui, - "Refresh guardian", - guardianStats - ? `on (${guardianStats.refreshed} refreshed, ${guardianStats.failed} failed)` - : "off", - guardianStats ? "success" : "muted", - ), - formatUiKeyValue(ui, "Last upstream request", lastRequest, "muted"), - ]; - if (runtimeMetrics.lastError) { - styled.push(formatUiKeyValue(ui, "Last error", runtimeMetrics.lastError, "danger")); - } - return Promise.resolve(styled.join("\n")); + return Promise.resolve(lines.join("\n")); + }, + }), } - - return Promise.resolve(lines.join("\n")); - }, - }), - } : {}), - "codex-health": tool({ - description: "Check health of all Codex accounts by validating refresh tokens.", + : {}), + "codex-health": tool({ + description: + "Check health of all Codex accounts by validating refresh tokens.", args: {}, async execute() { const ui = resolveUiRuntime(); @@ -4045,23 +3976,32 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { const label = formatAccountLabel(account, i); try { - const refreshResult = await queuedRefresh(account.refreshToken); + const refreshResult = await queuedRefresh(account.refreshToken); if (refreshResult.type === "success") { - results.push(` ${getStatusMarker(ui, "ok")} ${label}: Healthy`); + results.push( + ` ${getStatusMarker(ui, "ok")} ${label}: Healthy`, + ); healthyCount++; } else { - results.push(` ${getStatusMarker(ui, "error")} ${label}: Token refresh failed`); + results.push( + ` ${getStatusMarker(ui, "error")} ${label}: Token refresh failed`, + ); unhealthyCount++; } } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - results.push(` ${getStatusMarker(ui, "error")} ${label}: Error - ${errorMsg.slice(0, 120)}`); + const errorMsg = + error instanceof Error ? error.message : String(error); + results.push( + ` ${getStatusMarker(ui, "error")} ${label}: Error - ${errorMsg.slice(0, 120)}`, + ); unhealthyCount++; } } results.push(""); - results.push(`Summary: ${healthyCount} healthy, ${unhealthyCount} unhealthy`); + results.push( + `Summary: ${healthyCount} healthy, ${unhealthyCount} unhealthy`, + ); if (ui.v2Enabled) { return [ @@ -4074,275 +4014,366 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { return results.join("\n"); }, }), - ...(exposeAdvancedCodexTools ? { - "codex-remove": tool({ - description: "Remove a Codex account by index (1-based). Use codex-list to list accounts first.", - args: { - index: tool.schema.number().describe( - "Account number to remove (1-based, e.g., 1 for first account)", - ), - }, - async execute({ index }) { - const ui = resolveUiRuntime(); - const storage = await loadAccounts(); - if (!storage || storage.accounts.length === 0) { - if (ui.v2Enabled) { - return [ - ...formatUiHeader(ui, "Remove account"), - "", - formatUiItem(ui, "No accounts configured.", "warning"), - ].join("\n"); - } - return "No Codex accounts configured. Nothing to remove."; - } + ...(exposeAdvancedCodexTools + ? { + "codex-remove": tool({ + description: + "Remove a Codex account by index (1-based). Use codex-list to list accounts first.", + args: { + index: tool.schema + .number() + .describe( + "Account number to remove (1-based, e.g., 1 for first account)", + ), + }, + async execute({ index }) { + const ui = resolveUiRuntime(); + const storage = await loadAccounts(); + if (!storage || storage.accounts.length === 0) { + if (ui.v2Enabled) { + return [ + ...formatUiHeader(ui, "Remove account"), + "", + formatUiItem(ui, "No accounts configured.", "warning"), + ].join("\n"); + } + return "No Codex accounts configured. Nothing to remove."; + } - const targetIndex = Math.floor((index ?? 0) - 1); - if ( - !Number.isFinite(targetIndex) || - targetIndex < 0 || - targetIndex >= storage.accounts.length - ) { - if (ui.v2Enabled) { - return [ - ...formatUiHeader(ui, "Remove account"), - "", - formatUiItem(ui, `Invalid account number: ${index}`, "danger"), - formatUiKeyValue(ui, "Valid range", `1-${storage.accounts.length}`, "muted"), - formatUiItem(ui, "Use codex-list to list all accounts.", "accent"), - ].join("\n"); - } - return `Invalid account number: ${index}\n\nValid range: 1-${storage.accounts.length}\n\nUse codex-list to list all accounts.`; - } + const targetIndex = Math.floor((index ?? 0) - 1); + if ( + !Number.isFinite(targetIndex) || + targetIndex < 0 || + targetIndex >= storage.accounts.length + ) { + if (ui.v2Enabled) { + return [ + ...formatUiHeader(ui, "Remove account"), + "", + formatUiItem( + ui, + `Invalid account number: ${index}`, + "danger", + ), + formatUiKeyValue( + ui, + "Valid range", + `1-${storage.accounts.length}`, + "muted", + ), + formatUiItem( + ui, + "Use codex-list to list all accounts.", + "accent", + ), + ].join("\n"); + } + return `Invalid account number: ${index}\n\nValid range: 1-${storage.accounts.length}\n\nUse codex-list to list all accounts.`; + } - const account = storage.accounts[targetIndex]; - if (!account) { - return `Account ${index} not found.`; - } + const account = storage.accounts[targetIndex]; + if (!account) { + return `Account ${index} not found.`; + } - const label = formatAccountLabel(account, targetIndex); + const label = formatAccountLabel(account, targetIndex); - storage.accounts.splice(targetIndex, 1); + storage.accounts.splice(targetIndex, 1); - if (storage.accounts.length === 0) { - storage.activeIndex = 0; - storage.activeIndexByFamily = {}; - } else { - if (storage.activeIndex >= storage.accounts.length) { - storage.activeIndex = 0; - } else if (storage.activeIndex > targetIndex) { - storage.activeIndex -= 1; - } + if (storage.accounts.length === 0) { + storage.activeIndex = 0; + storage.activeIndexByFamily = {}; + } else { + if (storage.activeIndex >= storage.accounts.length) { + storage.activeIndex = 0; + } else if (storage.activeIndex > targetIndex) { + storage.activeIndex -= 1; + } - if (storage.activeIndexByFamily) { - for (const family of MODEL_FAMILIES) { - const idx = storage.activeIndexByFamily[family]; - if (typeof idx === "number") { - if (idx >= storage.accounts.length) { - storage.activeIndexByFamily[family] = 0; - } else if (idx > targetIndex) { - storage.activeIndexByFamily[family] = idx - 1; + if (storage.activeIndexByFamily) { + for (const family of MODEL_FAMILIES) { + const idx = storage.activeIndexByFamily[family]; + if (typeof idx === "number") { + if (idx >= storage.accounts.length) { + storage.activeIndexByFamily[family] = 0; + } else if (idx > targetIndex) { + storage.activeIndexByFamily[family] = idx - 1; + } + } + } } } - } - } - } - - try { - await saveAccounts(storage); - } catch (saveError) { - logWarn("Failed to save account removal", { error: String(saveError) }); - if (ui.v2Enabled) { - return [ - ...formatUiHeader(ui, "Remove account"), - "", - formatUiItem(ui, `Removed ${formatAccountLabel(account, targetIndex)} from memory`, "warning"), - formatUiItem(ui, "Failed to persist. Change may be lost on restart.", "danger"), - ].join("\n"); - } - return `Removed ${formatAccountLabel(account, targetIndex)} from memory but failed to persist. Changes may be lost on restart.`; - } - - if (cachedAccountManager) { - await reloadAccountManagerFromDisk(); - } - - const remaining = storage.accounts.length; - if (ui.v2Enabled) { - return [ - ...formatUiHeader(ui, "Remove account"), - "", - formatUiItem(ui, `${getStatusMarker(ui, "ok")} Removed: ${label}`, "success"), - remaining > 0 - ? formatUiKeyValue(ui, "Remaining accounts", String(remaining)) - : formatUiItem(ui, "No accounts remaining. Run: codex login", "warning"), - ].join("\n"); - } - return [ - `Removed: ${label}`, - "", - remaining > 0 - ? `Remaining accounts: ${remaining}` - : "No accounts remaining. Run: codex login", - ].join("\n"); - }, - }), - "codex-refresh": tool({ - description: "Manually refresh OAuth tokens for all accounts to verify they're still valid.", - args: {}, - async execute() { - const ui = resolveUiRuntime(); - const storage = await loadAccounts(); - if (!storage || storage.accounts.length === 0) { - if (ui.v2Enabled) { - return [ - ...formatUiHeader(ui, "Refresh accounts"), - "", - formatUiItem(ui, "No accounts configured.", "warning"), - formatUiItem(ui, "Run: codex login", "accent"), - ].join("\n"); - } - return "No Codex accounts configured. Run: codex login"; - } + try { + await saveAccounts(storage); + } catch (saveError) { + logWarn("Failed to save account removal", { + error: String(saveError), + }); + if (ui.v2Enabled) { + return [ + ...formatUiHeader(ui, "Remove account"), + "", + formatUiItem( + ui, + `Removed ${formatAccountLabel(account, targetIndex)} from memory`, + "warning", + ), + formatUiItem( + ui, + "Failed to persist. Change may be lost on restart.", + "danger", + ), + ].join("\n"); + } + return `Removed ${formatAccountLabel(account, targetIndex)} from memory but failed to persist. Changes may be lost on restart.`; + } - const results: string[] = ui.v2Enabled - ? [] - : [`Refreshing ${storage.accounts.length} account(s):`, ""]; + if (cachedAccountManager) { + await reloadAccountManagerFromDisk(); + } - let refreshedCount = 0; - let failedCount = 0; + const remaining = storage.accounts.length; + if (ui.v2Enabled) { + return [ + ...formatUiHeader(ui, "Remove account"), + "", + formatUiItem( + ui, + `${getStatusMarker(ui, "ok")} Removed: ${label}`, + "success", + ), + remaining > 0 + ? formatUiKeyValue( + ui, + "Remaining accounts", + String(remaining), + ) + : formatUiItem( + ui, + "No accounts remaining. Run: codex login", + "warning", + ), + ].join("\n"); + } + return [ + `Removed: ${label}`, + "", + remaining > 0 + ? `Remaining accounts: ${remaining}` + : "No accounts remaining. Run: codex login", + ].join("\n"); + }, + }), + + "codex-refresh": tool({ + description: + "Manually refresh OAuth tokens for all accounts to verify they're still valid.", + args: {}, + async execute() { + const ui = resolveUiRuntime(); + const storage = await loadAccounts(); + if (!storage || storage.accounts.length === 0) { + if (ui.v2Enabled) { + return [ + ...formatUiHeader(ui, "Refresh accounts"), + "", + formatUiItem(ui, "No accounts configured.", "warning"), + formatUiItem(ui, "Run: codex login", "accent"), + ].join("\n"); + } + return "No Codex accounts configured. Run: codex login"; + } - for (let i = 0; i < storage.accounts.length; i++) { - const account = storage.accounts[i]; - if (!account) continue; - const label = formatAccountLabel(account, i); + const results: string[] = ui.v2Enabled + ? [] + : [`Refreshing ${storage.accounts.length} account(s):`, ""]; - try { - const refreshResult = await queuedRefresh(account.refreshToken); - if (refreshResult.type === "success") { - account.refreshToken = refreshResult.refresh; - account.accessToken = refreshResult.access; - account.expiresAt = refreshResult.expires; - results.push(` ${getStatusMarker(ui, "ok")} ${label}: Refreshed`); - refreshedCount++; - } else { - results.push(` ${getStatusMarker(ui, "error")} ${label}: Failed - ${refreshResult.message ?? refreshResult.reason}`); - failedCount++; - } - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - results.push(` ${getStatusMarker(ui, "error")} ${label}: Error - ${errorMsg.slice(0, 120)}`); - failedCount++; - } - } + let refreshedCount = 0; + let failedCount = 0; - await saveAccounts(storage); - if (cachedAccountManager) { - await reloadAccountManagerFromDisk(); - } - results.push(""); - results.push(`Summary: ${refreshedCount} refreshed, ${failedCount} failed`); - if (ui.v2Enabled) { - return [ - ...formatUiHeader(ui, "Refresh accounts"), - "", - ...results.map((line) => paintUiText(ui, line, "normal")), - ].join("\n"); - } - return results.join("\n"); - }, - }), + for (let i = 0; i < storage.accounts.length; i++) { + const account = storage.accounts[i]; + if (!account) continue; + const label = formatAccountLabel(account, i); - "codex-export": tool({ - description: "Export accounts to a JSON file for backup or migration to another machine.", - args: { - path: tool.schema.string().describe( - "File path to export to (e.g., ~/codex-backup.json)" - ), - force: tool.schema.boolean().optional().describe( - "Overwrite existing file (default: true)" - ), - }, - async execute({ path: filePath, force }) { - const ui = resolveUiRuntime(); - try { - await exportAccounts(filePath, force ?? true); - const storage = await loadAccounts(); - const count = storage?.accounts.length ?? 0; - if (ui.v2Enabled) { - return [ - ...formatUiHeader(ui, "Export accounts"), - "", - formatUiItem(ui, `${getStatusMarker(ui, "ok")} Exported ${count} account(s)`, "success"), - formatUiKeyValue(ui, "Path", filePath, "muted"), - ].join("\n"); - } - return `Exported ${count} account(s) to: ${filePath}`; - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - if (ui.v2Enabled) { - return [ - ...formatUiHeader(ui, "Export accounts"), - "", - formatUiItem(ui, `${getStatusMarker(ui, "error")} Export failed`, "danger"), - formatUiKeyValue(ui, "Error", msg, "danger"), - ].join("\n"); - } - return `Export failed: ${msg}`; - } - }, - }), + try { + const refreshResult = await queuedRefresh( + account.refreshToken, + ); + if (refreshResult.type === "success") { + account.refreshToken = refreshResult.refresh; + account.accessToken = refreshResult.access; + account.expiresAt = refreshResult.expires; + results.push( + ` ${getStatusMarker(ui, "ok")} ${label}: Refreshed`, + ); + refreshedCount++; + } else { + results.push( + ` ${getStatusMarker(ui, "error")} ${label}: Failed - ${refreshResult.message ?? refreshResult.reason}`, + ); + failedCount++; + } + } catch (error) { + const errorMsg = + error instanceof Error ? error.message : String(error); + results.push( + ` ${getStatusMarker(ui, "error")} ${label}: Error - ${errorMsg.slice(0, 120)}`, + ); + failedCount++; + } + } - "codex-import": tool({ - description: "Import accounts from a JSON file, merging with existing accounts.", - args: { - path: tool.schema.string().describe( - "File path to import from (e.g., ~/codex-backup.json)" - ), - }, - async execute({ path: filePath }) { - const ui = resolveUiRuntime(); - try { - const result = await importAccounts(filePath); - invalidateAccountManagerCache(); - const lines = [`Import complete.`, ``]; - if (result.imported > 0) { - lines.push(`New accounts: ${result.imported}`); - } - if (result.skipped > 0) { - lines.push(`Duplicates skipped: ${result.skipped}`); - } - lines.push(`Total accounts: ${result.total}`); - if (ui.v2Enabled) { - const styled = [ - ...formatUiHeader(ui, "Import accounts"), - "", - formatUiItem(ui, `${getStatusMarker(ui, "ok")} Import complete`, "success"), - formatUiKeyValue(ui, "Path", filePath, "muted"), - formatUiKeyValue(ui, "New accounts", String(result.imported), result.imported > 0 ? "success" : "muted"), - formatUiKeyValue(ui, "Duplicates skipped", String(result.skipped), result.skipped > 0 ? "warning" : "muted"), - formatUiKeyValue(ui, "Total accounts", String(result.total), "accent"), - ]; - return styled.join("\n"); - } - return lines.join("\n"); - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - if (ui.v2Enabled) { - return [ - ...formatUiHeader(ui, "Import accounts"), - "", - formatUiItem(ui, `${getStatusMarker(ui, "error")} Import failed`, "danger"), - formatUiKeyValue(ui, "Error", msg, "danger"), - ].join("\n"); + await saveAccounts(storage); + if (cachedAccountManager) { + await reloadAccountManagerFromDisk(); + } + results.push(""); + results.push( + `Summary: ${refreshedCount} refreshed, ${failedCount} failed`, + ); + if (ui.v2Enabled) { + return [ + ...formatUiHeader(ui, "Refresh accounts"), + "", + ...results.map((line) => paintUiText(ui, line, "normal")), + ].join("\n"); + } + return results.join("\n"); + }, + }), + + "codex-export": tool({ + description: + "Export accounts to a JSON file for backup or migration to another machine.", + args: { + path: tool.schema + .string() + .describe( + "File path to export to (e.g., ~/codex-backup.json)", + ), + force: tool.schema + .boolean() + .optional() + .describe("Overwrite existing file (default: true)"), + }, + async execute({ path: filePath, force }) { + const ui = resolveUiRuntime(); + try { + await exportAccounts(filePath, force ?? true); + const storage = await loadAccounts(); + const count = storage?.accounts.length ?? 0; + if (ui.v2Enabled) { + return [ + ...formatUiHeader(ui, "Export accounts"), + "", + formatUiItem( + ui, + `${getStatusMarker(ui, "ok")} Exported ${count} account(s)`, + "success", + ), + formatUiKeyValue(ui, "Path", filePath, "muted"), + ].join("\n"); + } + return `Exported ${count} account(s) to: ${filePath}`; + } catch (error) { + const msg = + error instanceof Error ? error.message : String(error); + if (ui.v2Enabled) { + return [ + ...formatUiHeader(ui, "Export accounts"), + "", + formatUiItem( + ui, + `${getStatusMarker(ui, "error")} Export failed`, + "danger", + ), + formatUiKeyValue(ui, "Error", msg, "danger"), + ].join("\n"); + } + return `Export failed: ${msg}`; + } + }, + }), + + "codex-import": tool({ + description: + "Import accounts from a JSON file, merging with existing accounts.", + args: { + path: tool.schema + .string() + .describe( + "File path to import from (e.g., ~/codex-backup.json)", + ), + }, + async execute({ path: filePath }) { + const ui = resolveUiRuntime(); + try { + const result = await importAccounts(filePath); + invalidateAccountManagerCache(); + const lines = [`Import complete.`, ``]; + if (result.imported > 0) { + lines.push(`New accounts: ${result.imported}`); + } + if (result.skipped > 0) { + lines.push(`Duplicates skipped: ${result.skipped}`); + } + lines.push(`Total accounts: ${result.total}`); + if (ui.v2Enabled) { + const styled = [ + ...formatUiHeader(ui, "Import accounts"), + "", + formatUiItem( + ui, + `${getStatusMarker(ui, "ok")} Import complete`, + "success", + ), + formatUiKeyValue(ui, "Path", filePath, "muted"), + formatUiKeyValue( + ui, + "New accounts", + String(result.imported), + result.imported > 0 ? "success" : "muted", + ), + formatUiKeyValue( + ui, + "Duplicates skipped", + String(result.skipped), + result.skipped > 0 ? "warning" : "muted", + ), + formatUiKeyValue( + ui, + "Total accounts", + String(result.total), + "accent", + ), + ]; + return styled.join("\n"); + } + return lines.join("\n"); + } catch (error) { + const msg = + error instanceof Error ? error.message : String(error); + if (ui.v2Enabled) { + return [ + ...formatUiHeader(ui, "Import accounts"), + "", + formatUiItem( + ui, + `${getStatusMarker(ui, "error")} Import failed`, + "danger", + ), + formatUiKeyValue(ui, "Error", msg, "danger"), + ].join("\n"); + } + return `Import failed: ${msg}`; + } + }, + }), } - return `Import failed: ${msg}`; - } - }, - }), - } : {}), - - }, + : {}), + }, }; }; diff --git a/lib/codex-manager/backend-category-helpers.ts b/lib/codex-manager/backend-category-helpers.ts new file mode 100644 index 00000000..0e5d37f3 --- /dev/null +++ b/lib/codex-manager/backend-category-helpers.ts @@ -0,0 +1,60 @@ +import type { PluginConfig } from "../types.js"; +import type { + BackendCategoryKey, + BackendCategoryOption, + BackendNumberSettingKey, + BackendNumberSettingOption, + BackendSettingFocusKey, + BackendToggleSettingKey, +} from "./backend-settings-schema.js"; + +export function resolveFocusedBackendNumberKey( + focus: BackendSettingFocusKey, + numberOptions: BackendNumberSettingOption[], +): BackendNumberSettingKey { + const numberKeys = new Set( + numberOptions.map((option) => option.key), + ); + if (focus && numberKeys.has(focus as BackendNumberSettingKey)) { + return focus as BackendNumberSettingKey; + } + return numberOptions[0]?.key ?? "fetchTimeoutMs"; +} + +export function getBackendCategory( + key: BackendCategoryKey, + categoryOptions: readonly BackendCategoryOption[], +): BackendCategoryOption | null { + return categoryOptions.find((category) => category.key === key) ?? null; +} + +export function getBackendCategoryInitialFocus( + category: BackendCategoryOption, +): BackendSettingFocusKey { + const firstToggle = category.toggleKeys[0]; + if (firstToggle) return firstToggle; + return category.numberKeys[0] ?? null; +} + +export function applyBackendCategoryDefaults( + draft: PluginConfig, + category: BackendCategoryOption, + deps: { + backendDefaults: PluginConfig; + numberOptionByKey: ReadonlyMap< + BackendNumberSettingKey, + BackendNumberSettingOption + >; + }, +): PluginConfig { + const next = { ...draft }; + for (const key of category.toggleKeys) { + next[key as BackendToggleSettingKey] = deps.backendDefaults[key] ?? false; + } + for (const key of category.numberKeys) { + const option = deps.numberOptionByKey.get(key); + const fallback = option?.min ?? 0; + next[key] = deps.backendDefaults[key] ?? fallback; + } + return next; +} diff --git a/lib/codex-manager/backend-category-prompt.ts b/lib/codex-manager/backend-category-prompt.ts new file mode 100644 index 00000000..2341fb89 --- /dev/null +++ b/lib/codex-manager/backend-category-prompt.ts @@ -0,0 +1,308 @@ +import type { PluginConfig } from "../types.js"; +import type { UiRuntimeOptions } from "../ui/runtime.js"; +import type { MenuItem } from "../ui/select.js"; +import type { + BackendCategoryConfigAction, + BackendCategoryOption, + BackendNumberSettingKey, + BackendNumberSettingOption, + BackendSettingFocusKey, + BackendToggleSettingKey, + BackendToggleSettingOption, +} from "./backend-settings-schema.js"; + +export async function promptBackendCategorySettingsMenu(params: { + initial: PluginConfig; + category: BackendCategoryOption; + initialFocus: BackendSettingFocusKey; + ui: UiRuntimeOptions; + cloneBackendPluginConfig: (config: PluginConfig) => PluginConfig; + buildBackendSettingsPreview: ( + config: PluginConfig, + ui: UiRuntimeOptions, + focusKey: BackendSettingFocusKey, + deps: { + highlightPreviewToken: (text: string, ui: UiRuntimeOptions) => string; + }, + ) => { label: string; hint: string }; + highlightPreviewToken: (text: string, ui: UiRuntimeOptions) => string; + resolveFocusedBackendNumberKey: ( + focus: BackendSettingFocusKey, + numberOptions: BackendNumberSettingOption[], + ) => BackendNumberSettingKey; + clampBackendNumber: ( + option: BackendNumberSettingOption, + value: number, + ) => number; + formatBackendNumberValue: ( + option: BackendNumberSettingOption, + value: number, + ) => string; + formatDashboardSettingState: (enabled: boolean) => string; + applyBackendCategoryDefaults: ( + config: PluginConfig, + category: BackendCategoryOption, + ) => PluginConfig; + getBackendCategoryInitialFocus: ( + category: BackendCategoryOption, + ) => BackendSettingFocusKey; + backendDefaults: PluginConfig; + toggleOptionByKey: ReadonlyMap< + BackendToggleSettingKey, + BackendToggleSettingOption + >; + numberOptionByKey: ReadonlyMap< + BackendNumberSettingKey, + BackendNumberSettingOption + >; + select: ( + items: MenuItem[], + options: { + message: string; + subtitle: string; + help: string; + clearScreen: boolean; + theme: UiRuntimeOptions["theme"]; + selectedEmphasis: "minimal"; + initialCursor?: number; + onCursorChange: (event: { cursor: number }) => void; + onInput: (raw: string) => T | undefined; + }, + ) => Promise; + copy: { + previewHeading: string; + backendToggleHeading: string; + backendNumberHeading: string; + backendDecrease: string; + backendIncrease: string; + backendResetCategory: string; + backendBackToCategories: string; + backendCategoryTitle: string; + backendCategoryHelp: string; + }; +}): Promise<{ draft: PluginConfig; focusKey: BackendSettingFocusKey }> { + const { + initial, + category, + initialFocus, + ui, + cloneBackendPluginConfig, + buildBackendSettingsPreview, + highlightPreviewToken, + resolveFocusedBackendNumberKey, + clampBackendNumber, + formatBackendNumberValue, + formatDashboardSettingState, + applyBackendCategoryDefaults, + getBackendCategoryInitialFocus, + backendDefaults, + toggleOptionByKey, + numberOptionByKey, + select, + copy, + } = params; + + let draft = cloneBackendPluginConfig(initial); + let focusKey: BackendSettingFocusKey = initialFocus; + if ( + !focusKey || + (!category.toggleKeys.includes(focusKey as BackendToggleSettingKey) && + !category.numberKeys.includes(focusKey as BackendNumberSettingKey)) + ) { + focusKey = getBackendCategoryInitialFocus(category); + } + + const toggleOptions = category.toggleKeys + .map((key) => toggleOptionByKey.get(key)) + .filter((option): option is BackendToggleSettingOption => !!option); + const numberOptions = category.numberKeys + .map((key) => numberOptionByKey.get(key)) + .filter((option): option is BackendNumberSettingOption => !!option); + + while (true) { + const preview = buildBackendSettingsPreview(draft, ui, focusKey, { + highlightPreviewToken, + }); + const toggleItems: MenuItem[] = + toggleOptions.map((option, index) => { + const enabled = + draft[option.key] ?? backendDefaults[option.key] ?? false; + return { + label: `${formatDashboardSettingState(enabled)} ${index + 1}. ${option.label}`, + hint: option.description, + value: { type: "toggle", key: option.key }, + color: enabled ? "green" : "yellow", + }; + }); + const numberItems: MenuItem[] = + numberOptions.map((option) => { + const rawValue = + draft[option.key] ?? backendDefaults[option.key] ?? option.min; + const numericValue = + typeof rawValue === "number" && Number.isFinite(rawValue) + ? rawValue + : option.min; + const clampedValue = clampBackendNumber(option, numericValue); + const valueLabel = formatBackendNumberValue(option, clampedValue); + return { + label: `${option.label}: ${valueLabel}`, + hint: `${option.description} Step ${formatBackendNumberValue(option, option.step)}.`, + value: { type: "bump", key: option.key, direction: 1 }, + color: "yellow", + }; + }); + + const focusedNumberKey = resolveFocusedBackendNumberKey( + focusKey, + numberOptions, + ); + const items: MenuItem[] = [ + { label: copy.previewHeading, value: { type: "back" }, kind: "heading" }, + { + label: preview.label, + hint: preview.hint, + value: { type: "back" }, + disabled: true, + color: "green", + hideUnavailableSuffix: true, + }, + { label: "", value: { type: "back" }, separator: true }, + { + label: copy.backendToggleHeading, + value: { type: "back" }, + kind: "heading", + }, + ...toggleItems, + { label: "", value: { type: "back" }, separator: true }, + { + label: copy.backendNumberHeading, + value: { type: "back" }, + kind: "heading", + }, + ...numberItems, + ]; + + if (numberOptions.length > 0) { + items.push({ label: "", value: { type: "back" }, separator: true }); + items.push({ + label: copy.backendDecrease, + value: { type: "bump", key: focusedNumberKey, direction: -1 }, + color: "yellow", + }); + items.push({ + label: copy.backendIncrease, + value: { type: "bump", key: focusedNumberKey, direction: 1 }, + color: "green", + }); + } + + items.push({ label: "", value: { type: "back" }, separator: true }); + items.push({ + label: copy.backendResetCategory, + value: { type: "reset-category" }, + color: "yellow", + }); + items.push({ + label: copy.backendBackToCategories, + value: { type: "back" }, + color: "red", + }); + + const initialCursor = items.findIndex((item) => { + if (item.separator || item.disabled || item.kind === "heading") + return false; + if (item.value.type === "toggle" && focusKey === item.value.key) + return true; + if (item.value.type === "bump" && focusKey === item.value.key) + return true; + return false; + }); + + const result = await select(items, { + message: `${copy.backendCategoryTitle}: ${category.label}`, + subtitle: category.description, + help: copy.backendCategoryHelp, + clearScreen: true, + theme: ui.theme, + selectedEmphasis: "minimal", + initialCursor: initialCursor >= 0 ? initialCursor : undefined, + onCursorChange: ({ cursor }) => { + const focusedItem = items[cursor]; + if ( + focusedItem?.value.type === "toggle" || + focusedItem?.value.type === "bump" + ) { + focusKey = focusedItem.value.key; + } + }, + onInput: (raw) => { + const lower = raw.toLowerCase(); + if (lower === "q") return { type: "back" as const }; + if (lower === "r") return { type: "reset-category" as const }; + if ( + numberOptions.length > 0 && + (lower === "+" || lower === "=" || lower === "]" || lower === "d") + ) { + return { + type: "bump" as const, + key: resolveFocusedBackendNumberKey(focusKey, numberOptions), + direction: 1 as const, + }; + } + if ( + numberOptions.length > 0 && + (lower === "-" || lower === "[" || lower === "a") + ) { + return { + type: "bump" as const, + key: resolveFocusedBackendNumberKey(focusKey, numberOptions), + direction: -1 as const, + }; + } + const parsed = Number.parseInt(raw, 10); + if ( + Number.isFinite(parsed) && + parsed >= 1 && + parsed <= toggleOptions.length + ) { + const target = toggleOptions[parsed - 1]; + if (target) return { type: "toggle" as const, key: target.key }; + } + return undefined; + }, + }); + + if (!result || result.type === "back") { + return { draft, focusKey }; + } + if (result.type === "reset-category") { + draft = applyBackendCategoryDefaults(draft, category); + focusKey = getBackendCategoryInitialFocus(category); + continue; + } + if (result.type === "toggle") { + const currentValue = + draft[result.key] ?? backendDefaults[result.key] ?? false; + draft = { ...draft, [result.key]: !currentValue }; + focusKey = result.key; + continue; + } + + const option = numberOptionByKey.get(result.key); + if (!option) continue; + const currentValue = + draft[result.key] ?? backendDefaults[result.key] ?? option.min; + const numericCurrent = + typeof currentValue === "number" && Number.isFinite(currentValue) + ? currentValue + : option.min; + draft = { + ...draft, + [result.key]: clampBackendNumber( + option, + numericCurrent + option.step * result.direction, + ), + }; + focusKey = result.key; + } +} diff --git a/lib/codex-manager/backend-settings-controller.ts b/lib/codex-manager/backend-settings-controller.ts new file mode 100644 index 00000000..e5c75066 --- /dev/null +++ b/lib/codex-manager/backend-settings-controller.ts @@ -0,0 +1,33 @@ +import type { PluginConfig } from "../types.js"; + +export async function configureBackendSettingsController( + currentConfig: PluginConfig | undefined, + deps: { + cloneBackendPluginConfig: (config: PluginConfig) => PluginConfig; + loadPluginConfig: () => PluginConfig; + promptBackendSettings: ( + config: PluginConfig, + ) => Promise; + backendSettingsEqual: (left: PluginConfig, right: PluginConfig) => boolean; + persistBackendConfigSelection: ( + config: PluginConfig, + scope: string, + ) => Promise; + isInteractive: () => boolean; + writeLine: (message: string) => void; + }, +): Promise { + const current = deps.cloneBackendPluginConfig( + currentConfig ?? deps.loadPluginConfig(), + ); + if (!deps.isInteractive()) { + deps.writeLine("Settings require interactive mode."); + return current; + } + + const selected = await deps.promptBackendSettings(current); + if (!selected) return current; + if (deps.backendSettingsEqual(current, selected)) return current; + + return deps.persistBackendConfigSelection(selected, "backend"); +} diff --git a/lib/codex-manager/backend-settings-entry.ts b/lib/codex-manager/backend-settings-entry.ts new file mode 100644 index 00000000..4c87895c --- /dev/null +++ b/lib/codex-manager/backend-settings-entry.ts @@ -0,0 +1,49 @@ +import type { PluginConfig } from "../types.js"; + +export async function configureBackendSettingsEntry( + currentConfig: PluginConfig | undefined, + deps: { + configureBackendSettingsController: ( + currentConfig: PluginConfig | undefined, + deps: { + cloneBackendPluginConfig: (config: PluginConfig) => PluginConfig; + loadPluginConfig: () => PluginConfig; + promptBackendSettings: ( + config: PluginConfig, + ) => Promise; + backendSettingsEqual: ( + left: PluginConfig, + right: PluginConfig, + ) => boolean; + persistBackendConfigSelection: ( + config: PluginConfig, + scope: string, + ) => Promise; + isInteractive: () => boolean; + writeLine: (message: string) => void; + }, + ) => Promise; + cloneBackendPluginConfig: (config: PluginConfig) => PluginConfig; + loadPluginConfig: () => PluginConfig; + promptBackendSettings: ( + config: PluginConfig, + ) => Promise; + backendSettingsEqual: (left: PluginConfig, right: PluginConfig) => boolean; + persistBackendConfigSelection: ( + config: PluginConfig, + scope: string, + ) => Promise; + isInteractive: () => boolean; + writeLine: (message: string) => void; + }, +): Promise { + return deps.configureBackendSettingsController(currentConfig, { + cloneBackendPluginConfig: deps.cloneBackendPluginConfig, + loadPluginConfig: deps.loadPluginConfig, + promptBackendSettings: deps.promptBackendSettings, + backendSettingsEqual: deps.backendSettingsEqual, + persistBackendConfigSelection: deps.persistBackendConfigSelection, + isInteractive: deps.isInteractive, + writeLine: deps.writeLine, + }); +} diff --git a/lib/codex-manager/dashboard-settings-controller.ts b/lib/codex-manager/dashboard-settings-controller.ts new file mode 100644 index 00000000..c885f3ba --- /dev/null +++ b/lib/codex-manager/dashboard-settings-controller.ts @@ -0,0 +1,40 @@ +import type { DashboardDisplaySettings } from "../dashboard-settings.js"; + +export async function configureDashboardSettingsController( + currentSettings: DashboardDisplaySettings | undefined, + deps: { + loadDashboardDisplaySettings: () => Promise; + promptSettings: ( + settings: DashboardDisplaySettings, + ) => Promise; + settingsEqual: ( + left: DashboardDisplaySettings, + right: DashboardDisplaySettings, + ) => boolean; + persistSelection: ( + selected: DashboardDisplaySettings, + ) => Promise; + applyUiThemeFromDashboardSettings: ( + settings: DashboardDisplaySettings, + ) => void; + isInteractive: () => boolean; + getDashboardSettingsPath: () => string; + writeLine: (message: string) => void; + }, +): Promise { + const current = + currentSettings ?? (await deps.loadDashboardDisplaySettings()); + if (!deps.isInteractive()) { + deps.writeLine("Settings require interactive mode."); + deps.writeLine(`Settings file: ${deps.getDashboardSettingsPath()}`); + return current; + } + + const selected = await deps.promptSettings(current); + if (!selected) return current; + if (deps.settingsEqual(current, selected)) return current; + + const merged = await deps.persistSelection(selected); + deps.applyUiThemeFromDashboardSettings(merged); + return merged; +} diff --git a/lib/codex-manager/experimental-settings-prompt.ts b/lib/codex-manager/experimental-settings-prompt.ts new file mode 100644 index 00000000..345513f7 --- /dev/null +++ b/lib/codex-manager/experimental-settings-prompt.ts @@ -0,0 +1,343 @@ +import { createInterface } from "node:readline/promises"; +import type { PluginConfig } from "../types.js"; +import type { UiRuntimeOptions } from "../ui/runtime.js"; + +export async function promptExperimentalSettingsMenu< + TAction, + TTargetState, + TPlan, + TApplied, +>(params: { + initialConfig: PluginConfig; + isInteractive: () => boolean; + ui: UiRuntimeOptions; + cloneBackendPluginConfig: (config: PluginConfig) => PluginConfig; + select: ( + items: Array>, + options: Record, + ) => Promise; + getExperimentalSelectOptions: ( + ui: UiRuntimeOptions, + help: string, + hotkeyMapper: (raw: string) => TAction | undefined, + ) => Record; + mapExperimentalMenuHotkey: (raw: string) => TAction | undefined; + mapExperimentalStatusHotkey: (raw: string) => TAction | undefined; + formatDashboardSettingState: (enabled: boolean) => string; + copy: { + experimentalSync: string; + experimentalBackup: string; + experimentalRefreshGuard: string; + experimentalRefreshInterval: string; + experimentalDecreaseInterval: string; + experimentalIncreaseInterval: string; + saveAndBack: string; + backNoSave: string; + experimentalHelpMenu: string; + experimentalBackupPrompt: string; + back: string; + experimentalHelpStatus: string; + experimentalApplySync: string; + experimentalHelpPreview: string; + }; + input: NodeJS.ReadStream; + output: NodeJS.WriteStream; + runNamedBackupExport: (args: { + name: string; + }) => Promise<{ kind: string; path?: string; error?: unknown }>; + loadAccounts: () => Promise; + loadExperimentalSyncTarget: () => Promise; + planOcChatgptSync: (args: Record) => Promise; + applyOcChatgptSync: (args: Record) => Promise; + getTargetKind: (targetState: TTargetState) => string; + getTargetDestination: (targetState: TTargetState) => unknown; + getTargetDetection: (targetState: TTargetState) => unknown; + getTargetErrorMessage: (targetState: TTargetState) => string | null; + getPlanKind: (plan: TPlan) => string; + getPlanBlockedReason: (plan: TPlan) => string; + getPlanPreview: (plan: TPlan) => { + toAdd: unknown[]; + toUpdate: unknown[]; + toSkip: unknown[]; + unchangedDestinationOnly: unknown[]; + activeSelectionBehavior: string; + }; + getAppliedLabel: (applied: TApplied) => { label: string; color: string }; +}): Promise { + if (!params.isInteractive()) return null; + let draft = params.cloneBackendPluginConfig(params.initialConfig); + const copy = params.copy; + + while (true) { + const action = await params.select( + [ + { + label: copy.experimentalSync, + value: { type: "sync" }, + color: "yellow", + }, + { + label: copy.experimentalBackup, + value: { type: "backup" }, + color: "green", + }, + { + label: `${params.formatDashboardSettingState(draft.proactiveRefreshGuardian ?? false)} ${copy.experimentalRefreshGuard}`, + value: { type: "toggle-refresh-guardian" }, + color: "yellow", + }, + { + label: `${copy.experimentalRefreshInterval}: ${Math.round((draft.proactiveRefreshIntervalMs ?? 60000) / 60000)} min`, + value: { type: "back" }, + disabled: true, + hideUnavailableSuffix: true, + color: "green", + }, + { + label: copy.experimentalDecreaseInterval, + value: { type: "decrease-refresh-interval" }, + color: "yellow", + }, + { + label: copy.experimentalIncreaseInterval, + value: { type: "increase-refresh-interval" }, + color: "green", + }, + { label: copy.saveAndBack, value: { type: "save" }, color: "green" }, + { label: copy.backNoSave, value: { type: "back" }, color: "red" }, + ], + params.getExperimentalSelectOptions( + params.ui, + copy.experimentalHelpMenu, + params.mapExperimentalMenuHotkey, + ), + ); + const actionType = (action as { type?: string } | null)?.type; + if (!action || actionType === "back") return null; + if (actionType === "save") return draft; + if (actionType === "toggle-refresh-guardian") { + draft = { + ...draft, + proactiveRefreshGuardian: !(draft.proactiveRefreshGuardian ?? false), + }; + continue; + } + if (actionType === "decrease-refresh-interval") { + draft = { + ...draft, + proactiveRefreshIntervalMs: Math.max( + 60_000, + (draft.proactiveRefreshIntervalMs ?? 60000) - 60000, + ), + }; + continue; + } + if (actionType === "increase-refresh-interval") { + draft = { + ...draft, + proactiveRefreshIntervalMs: Math.min( + 600000, + (draft.proactiveRefreshIntervalMs ?? 60000) + 60000, + ), + }; + continue; + } + if (actionType === "backup") { + const prompt = createInterface({ + input: params.input, + output: params.output, + }); + try { + const backupName = ( + await prompt.question(copy.experimentalBackupPrompt) + ).trim(); + if (!backupName || backupName.toLowerCase() === "q") continue; + try { + const backupResult = await params.runNamedBackupExport({ + name: backupName, + }); + const backupLabel = + backupResult.kind === "exported" + ? `Saved backup to ${backupResult.path}` + : backupResult.kind === "collision" + ? `Backup already exists: ${backupResult.path}` + : backupResult.error instanceof Error + ? backupResult.error.message + : String(backupResult.error); + await params.select( + [ + { + label: backupLabel, + value: { type: "back" }, + disabled: true, + hideUnavailableSuffix: true, + color: backupResult.kind === "exported" ? "green" : "yellow", + }, + { label: copy.back, value: { type: "back" }, color: "red" }, + ], + params.getExperimentalSelectOptions( + params.ui, + copy.experimentalHelpStatus, + params.mapExperimentalStatusHotkey, + ), + ); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + await params.select( + [ + { + label: message, + value: { type: "back" }, + disabled: true, + hideUnavailableSuffix: true, + color: "yellow", + }, + { label: copy.back, value: { type: "back" }, color: "red" }, + ], + params.getExperimentalSelectOptions( + params.ui, + copy.experimentalHelpStatus, + params.mapExperimentalStatusHotkey, + ), + ); + } + } finally { + prompt.close(); + } + continue; + } + + const source = await params.loadAccounts(); + const targetState = await params.loadExperimentalSyncTarget(); + const targetError = params.getTargetErrorMessage(targetState); + if (targetError) { + await params.select( + [ + { + label: targetError, + value: { type: "back" }, + disabled: true, + hideUnavailableSuffix: true, + color: "yellow", + }, + { label: copy.back, value: { type: "back" }, color: "red" }, + ], + params.getExperimentalSelectOptions( + params.ui, + copy.experimentalHelpStatus, + params.mapExperimentalStatusHotkey, + ), + ); + continue; + } + + const targetKind = params.getTargetKind(targetState); + const targetDetection = params.getTargetDetection(targetState); + const plan = await params.planOcChatgptSync({ + source, + destination: + targetKind === "target" + ? params.getTargetDestination(targetState) + : null, + dependencies: + targetKind === "target" + ? { detectTarget: () => targetDetection } + : undefined, + }); + if (params.getPlanKind(plan) !== "ready") { + await params.select( + [ + { + label: params.getPlanBlockedReason(plan), + value: { type: "back" }, + disabled: true, + hideUnavailableSuffix: true, + color: "yellow", + }, + { label: copy.back, value: { type: "back" }, color: "red" }, + ], + params.getExperimentalSelectOptions( + params.ui, + copy.experimentalHelpStatus, + params.mapExperimentalStatusHotkey, + ), + ); + continue; + } + + const preview = params.getPlanPreview(plan); + const review = await params.select( + [ + { + label: `Preview: add ${preview.toAdd.length} | update ${preview.toUpdate.length} | skip ${preview.toSkip.length}`, + value: { type: "back" }, + disabled: true, + hideUnavailableSuffix: true, + color: "green", + }, + { + label: `Preserve destination-only: ${preview.unchangedDestinationOnly.length}`, + value: { type: "back" }, + disabled: true, + hideUnavailableSuffix: true, + color: "green", + }, + { + label: `Active selection: ${preview.activeSelectionBehavior}`, + value: { type: "back" }, + disabled: true, + hideUnavailableSuffix: true, + color: "green", + }, + { + label: copy.experimentalApplySync, + value: { type: "apply" }, + color: "green", + }, + { label: copy.backNoSave, value: { type: "back" }, color: "red" }, + ], + params.getExperimentalSelectOptions( + params.ui, + copy.experimentalHelpPreview, + (raw) => { + const lower = raw.toLowerCase(); + if (lower === "q") return { type: "back" } as TAction; + if (lower === "a") return { type: "apply" } as TAction; + return undefined; + }, + ), + ); + if (!review || (review as { type?: string }).type === "back") continue; + + const applied = await params.applyOcChatgptSync({ + source, + destination: + targetKind === "target" + ? params.getTargetDestination(targetState) + : undefined, + dependencies: + targetKind === "target" + ? { detectTarget: () => targetDetection } + : undefined, + }); + const appliedLabel = params.getAppliedLabel(applied); + await params.select( + [ + { + label: appliedLabel.label, + value: { type: "back" }, + disabled: true, + hideUnavailableSuffix: true, + color: appliedLabel.color, + }, + { label: copy.back, value: { type: "back" }, color: "red" }, + ], + params.getExperimentalSelectOptions( + params.ui, + copy.experimentalHelpStatus, + params.mapExperimentalStatusHotkey, + ), + ); + } +} diff --git a/lib/codex-manager/experimental-sync-target.ts b/lib/codex-manager/experimental-sync-target.ts new file mode 100644 index 00000000..46f8f424 --- /dev/null +++ b/lib/codex-manager/experimental-sync-target.ts @@ -0,0 +1,63 @@ +import type { AccountStorageV3 } from "../storage.js"; + +type ExperimentalTargetDetection = ReturnType< + typeof import("../oc-chatgpt-target-detection.js").detectOcChatgptMultiAuthTarget +>; + +export type ExperimentalSyncTargetState = + | { + kind: "blocked-ambiguous"; + detection: ExperimentalTargetDetection; + } + | { + kind: "blocked-none"; + detection: ExperimentalTargetDetection; + } + | { kind: "error"; message: string } + | { + kind: "target"; + detection: ExperimentalTargetDetection; + destination: AccountStorageV3 | null; + }; + +export async function loadExperimentalSyncTargetState(deps: { + detectTarget: () => ExperimentalTargetDetection; + readJson: (path: string) => Promise; + normalizeAccountStorage: (value: unknown) => AccountStorageV3 | null; +}): Promise { + const detection = deps.detectTarget(); + if (detection.kind === "ambiguous") { + return { kind: "blocked-ambiguous", detection }; + } + if (detection.kind === "none") { + return { kind: "blocked-none", detection }; + } + try { + const raw = await deps.readJson(detection.descriptor.accountPath); + const normalized = deps.normalizeAccountStorage(raw); + if (!normalized) { + return { + kind: "error", + message: "Invalid target account storage format", + }; + } + return { + kind: "target", + detection, + destination: normalized, + }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return { + kind: "target", + detection, + destination: null, + }; + } + return { + kind: "error", + message: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/lib/codex-manager/settings-hub-menu.ts b/lib/codex-manager/settings-hub-menu.ts new file mode 100644 index 00000000..0932af1f --- /dev/null +++ b/lib/codex-manager/settings-hub-menu.ts @@ -0,0 +1,62 @@ +import type { MenuItem } from "../ui/select.js"; + +export type SettingsHubMenuAction = + | { type: "account-list" } + | { type: "summary-fields" } + | { type: "behavior" } + | { type: "theme" } + | { type: "experimental" } + | { type: "backend" } + | { type: "back" }; + +export function buildSettingsHubItems(copy: { + sectionTitle: string; + accountList: string; + summaryFields: string; + behavior: string; + theme: string; + advancedTitle: string; + experimental: string; + backend: string; + exitTitle: string; + back: string; +}): MenuItem[] { + return [ + { label: copy.sectionTitle, value: { type: "back" }, kind: "heading" }, + { + label: copy.accountList, + value: { type: "account-list" }, + color: "green", + }, + { + label: copy.summaryFields, + value: { type: "summary-fields" }, + color: "green", + }, + { label: copy.behavior, value: { type: "behavior" }, color: "green" }, + { label: copy.theme, value: { type: "theme" }, color: "green" }, + { label: "", value: { type: "back" }, separator: true }, + { label: copy.advancedTitle, value: { type: "back" }, kind: "heading" }, + { + label: copy.experimental, + value: { type: "experimental" }, + color: "yellow", + }, + { label: copy.backend, value: { type: "backend" }, color: "green" }, + { label: "", value: { type: "back" }, separator: true }, + { label: copy.exitTitle, value: { type: "back" }, kind: "heading" }, + { label: copy.back, value: { type: "back" }, color: "red" }, + ]; +} + +export function findSettingsHubInitialCursor( + items: MenuItem[], + initialFocus: SettingsHubMenuAction["type"], +): number | undefined { + const index = items.findIndex((item) => { + if (item.separator || item.disabled || item.kind === "heading") + return false; + return item.value.type === initialFocus; + }); + return index >= 0 ? index : undefined; +} diff --git a/lib/codex-manager/settings-hub-prompt.ts b/lib/codex-manager/settings-hub-prompt.ts new file mode 100644 index 00000000..865429d3 --- /dev/null +++ b/lib/codex-manager/settings-hub-prompt.ts @@ -0,0 +1,53 @@ +import type { UiRuntimeOptions } from "../ui/runtime.js"; +import type { MenuItem } from "../ui/select.js"; +import type { SettingsHubMenuAction } from "./settings-hub-menu.js"; + +export async function promptSettingsHubMenu( + initialFocus: SettingsHubMenuAction["type"], + deps: { + isInteractive: () => boolean; + getUiRuntimeOptions: () => UiRuntimeOptions; + buildItems: () => MenuItem[]; + findInitialCursor: ( + items: MenuItem[], + initialFocus: SettingsHubMenuAction["type"], + ) => number | undefined; + select: ( + items: MenuItem[], + options: { + message: string; + subtitle: string; + help: string; + clearScreen: boolean; + theme: UiRuntimeOptions["theme"]; + selectedEmphasis: "minimal"; + initialCursor?: number; + onInput: (raw: string) => T | undefined; + }, + ) => Promise; + copy: { + title: string; + subtitle: string; + help: string; + }; + }, +): Promise { + if (!deps.isInteractive()) return null; + const ui = deps.getUiRuntimeOptions(); + const items = deps.buildItems(); + const initialCursor = deps.findInitialCursor(items, initialFocus); + return deps.select(items, { + message: deps.copy.title, + subtitle: deps.copy.subtitle, + help: deps.copy.help, + clearScreen: true, + theme: ui.theme, + selectedEmphasis: "minimal", + initialCursor, + onInput: (raw) => { + const lower = raw.toLowerCase(); + if (lower === "q") return { type: "back" }; + return undefined; + }, + }); +} diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index f0e9b108..dbfface6 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -1,5 +1,4 @@ import { stdin as input, stdout as output } from "node:process"; -import { createInterface } from "node:readline/promises"; import { loadPluginConfig, savePluginConfig } from "../config.js"; import { type DashboardAccentColor, @@ -19,11 +18,19 @@ import { import { detectOcChatgptMultiAuthTarget } from "../oc-chatgpt-target-detection.js"; import { loadAccounts, normalizeAccountStorage } from "../storage.js"; import type { PluginConfig } from "../types.js"; -import { ANSI } from "../ui/ansi.js"; import { UI_COPY } from "../ui/copy.js"; import { getUiRuntimeOptions, setUiRuntimeOptions } from "../ui/runtime.js"; import { type MenuItem, select } from "../ui/select.js"; import { sleep } from "../utils.js"; +import { + applyBackendCategoryDefaults, + getBackendCategory, + getBackendCategoryInitialFocus, + resolveFocusedBackendNumberKey, +} from "./backend-category-helpers.js"; +import { promptBackendCategorySettingsMenu } from "./backend-category-prompt.js"; +import { configureBackendSettingsController } from "./backend-settings-controller.js"; +import { configureBackendSettingsEntry } from "./backend-settings-entry.js"; import { backendSettingsEqual, buildBackendConfigPatch, @@ -36,17 +43,12 @@ import { BACKEND_CATEGORY_OPTIONS, BACKEND_DEFAULTS, BACKEND_NUMBER_OPTION_BY_KEY, - BACKEND_NUMBER_OPTIONS, BACKEND_TOGGLE_OPTION_BY_KEY, - type BackendCategoryConfigAction, type BackendCategoryKey, type BackendCategoryOption, - type BackendNumberSettingKey, type BackendNumberSettingOption, type BackendSettingFocusKey, type BackendSettingsHubAction, - type BackendToggleSettingKey, - type BackendToggleSettingOption, } from "./backend-settings-schema.js"; import { promptBehaviorSettingsPanel } from "./behavior-settings-panel.js"; import { promptDashboardDisplayPanel } from "./dashboard-display-panel.js"; @@ -56,24 +58,41 @@ import { formatMenuQuotaTtl, formatMenuSortMode, } from "./dashboard-formatters.js"; +import { configureDashboardSettingsController } from "./dashboard-settings-controller.js"; import { cloneDashboardSettingsData, dashboardSettingsDataEqual, } from "./dashboard-settings-data.js"; +import { promptExperimentalSettingsMenu } from "./experimental-settings-prompt.js"; import { - type ExperimentalSettingsAction, getExperimentalSelectOptions, mapExperimentalMenuHotkey, mapExperimentalStatusHotkey, } from "./experimental-settings-schema.js"; +import { loadExperimentalSyncTargetState } from "./experimental-sync-target.js"; +import { + buildSettingsHubItems, + findSettingsHubInitialCursor, +} from "./settings-hub-menu.js"; +import { promptSettingsHubMenu } from "./settings-hub-prompt.js"; import { readFileWithRetry, resolvePluginConfigSavePathKey, warnPersistFailure, } from "./settings-persist-utils.js"; +import { + buildAccountListPreview as buildAccountListPreviewBase, + buildSummaryPreviewText as buildSummaryPreviewTextBase, + highlightPreviewToken, + normalizeStatuslineFields, +} from "./settings-preview.js"; import { withQueuedRetry } from "./settings-write-queue.js"; import { promptStatuslineSettingsPanel } from "./statusline-settings-panel.js"; import { promptThemeSettingsPanel } from "./theme-settings-panel.js"; +import { + configureUnifiedSettingsController, + type SettingsHubActionType, +} from "./unified-settings-controller.js"; type DashboardDisplaySettingKey = | "menuShowStatusBadge" @@ -147,11 +166,6 @@ const DASHBOARD_DISPLAY_OPTIONS: DashboardDisplaySettingOption[] = [ }, ]; -const DEFAULT_STATUSLINE_FIELDS: DashboardStatuslineField[] = [ - "last-used", - "limits", - "status", -]; const STATUSLINE_FIELD_OPTIONS: Array<{ key: DashboardStatuslineField; label: string; @@ -182,17 +196,6 @@ const ACCENT_COLOR_OPTIONS: DashboardAccentColor[] = [ "blue", "yellow", ]; -const PREVIEW_ACCOUNT_EMAIL = "demo@example.com"; -const PREVIEW_LAST_USED = "today"; -const PREVIEW_STATUS = "active"; -const PREVIEW_LIMITS = "5h ██████▒▒▒▒ 62% | 7d █████▒▒▒▒▒ 49%"; -const PREVIEW_LIMIT_COOLDOWNS = "5h reset 1h 20m | 7d reset 2d 04h"; -type PreviewFocusKey = - | DashboardDisplaySettingKey - | DashboardStatuslineField - | "menuSortMode" - | "menuLayoutMode" - | null; type SettingsHubAction = | { type: "account-list" } @@ -316,156 +319,6 @@ async function persistBackendConfigSelection( } } -function normalizeStatuslineFields( - fields: DashboardStatuslineField[] | undefined, -): DashboardStatuslineField[] { - const source = fields ?? DEFAULT_STATUSLINE_FIELDS; - const seen = new Set(); - const normalized: DashboardStatuslineField[] = []; - for (const field of source) { - if (seen.has(field)) continue; - seen.add(field); - normalized.push(field); - } - if (normalized.length === 0) { - return [...DEFAULT_STATUSLINE_FIELDS]; - } - return normalized; -} - -function highlightPreviewToken( - text: string, - ui: ReturnType, -): string { - if (!output.isTTY) return text; - if (ui.v2Enabled) { - return `${ui.theme.colors.accent}${ANSI.bold}${text}${ui.theme.colors.reset}`; - } - return `${ANSI.cyan}${ANSI.bold}${text}${ANSI.reset}`; -} - -function isLastUsedPreviewFocus(focus: PreviewFocusKey): boolean { - return focus === "menuShowLastUsed" || focus === "last-used"; -} - -function isLimitsPreviewFocus(focus: PreviewFocusKey): boolean { - return focus === "menuShowQuotaSummary" || focus === "limits"; -} - -function isLimitsCooldownPreviewFocus(focus: PreviewFocusKey): boolean { - return focus === "menuShowQuotaCooldown"; -} - -function isStatusPreviewFocus(focus: PreviewFocusKey): boolean { - return focus === "menuShowStatusBadge" || focus === "status"; -} - -function isCurrentBadgePreviewFocus(focus: PreviewFocusKey): boolean { - return focus === "menuShowCurrentBadge"; -} - -function isCurrentRowPreviewFocus(focus: PreviewFocusKey): boolean { - return focus === "menuHighlightCurrentRow"; -} - -function isExpandedRowsPreviewFocus(focus: PreviewFocusKey): boolean { - return ( - focus === "menuShowDetailsForUnselectedRows" || focus === "menuLayoutMode" - ); -} - -function buildSummaryPreviewText( - settings: DashboardDisplaySettings, - ui: ReturnType, - focus: PreviewFocusKey = null, -): string { - const partsByField = new Map(); - if (settings.menuShowLastUsed !== false) { - const part = `last used: ${PREVIEW_LAST_USED}`; - partsByField.set( - "last-used", - isLastUsedPreviewFocus(focus) ? highlightPreviewToken(part, ui) : part, - ); - } - if (settings.menuShowQuotaSummary !== false) { - const limitsText = - settings.menuShowQuotaCooldown === false - ? PREVIEW_LIMITS - : `${PREVIEW_LIMITS} | ${PREVIEW_LIMIT_COOLDOWNS}`; - const part = `limits: ${limitsText}`; - partsByField.set( - "limits", - isLimitsPreviewFocus(focus) || isLimitsCooldownPreviewFocus(focus) - ? highlightPreviewToken(part, ui) - : part, - ); - } - if (settings.menuShowStatusBadge === false) { - const part = `status: ${PREVIEW_STATUS}`; - partsByField.set( - "status", - isStatusPreviewFocus(focus) ? highlightPreviewToken(part, ui) : part, - ); - } - - const orderedParts = normalizeStatuslineFields(settings.menuStatuslineFields) - .map((field) => partsByField.get(field)) - .filter( - (part): part is string => typeof part === "string" && part.length > 0, - ); - if (orderedParts.length > 0) { - return orderedParts.join(" | "); - } - - const showsStatusField = normalizeStatuslineFields( - settings.menuStatuslineFields, - ).includes("status"); - if (showsStatusField && settings.menuShowStatusBadge !== false) { - const note = "status text appears only when status badges are hidden"; - return isStatusPreviewFocus(focus) ? highlightPreviewToken(note, ui) : note; - } - return "no summary text is visible with current account-list settings"; -} - -function buildAccountListPreview( - settings: DashboardDisplaySettings, - ui: ReturnType, - focus: PreviewFocusKey = null, -): { label: string; hint: string } { - const badges: string[] = []; - if (settings.menuShowCurrentBadge !== false) { - const currentBadge = "[current]"; - badges.push( - isCurrentBadgePreviewFocus(focus) - ? highlightPreviewToken(currentBadge, ui) - : currentBadge, - ); - } - if (settings.menuShowStatusBadge !== false) { - const statusBadge = "[active]"; - badges.push( - isStatusPreviewFocus(focus) - ? highlightPreviewToken(statusBadge, ui) - : statusBadge, - ); - } - const badgeSuffix = badges.length > 0 ? ` ${badges.join(" ")}` : ""; - const accountEmail = isCurrentRowPreviewFocus(focus) - ? highlightPreviewToken(PREVIEW_ACCOUNT_EMAIL, ui) - : PREVIEW_ACCOUNT_EMAIL; - const rowDetailMode = - resolveMenuLayoutMode(settings) === "expanded-rows" - ? "details shown on all rows" - : "details shown on selected row only"; - const detailModeText = isExpandedRowsPreviewFocus(focus) - ? highlightPreviewToken(rowDetailMode, ui) - : rowDetailMode; - return { - label: `1. ${accountEmail}${badgeSuffix}`, - hint: `${buildSummaryPreviewText(settings, ui, focus)}\n${detailModeText}`, - }; -} - function cloneDashboardSettings( settings: DashboardDisplaySettings, ): DashboardDisplaySettings { @@ -485,6 +338,42 @@ function dashboardSettingsEqual( }); } +function buildSummaryPreviewText( + settings: DashboardDisplaySettings, + ui: ReturnType, + focus: + | DashboardDisplaySettingKey + | DashboardStatuslineField + | "menuSortMode" + | "menuLayoutMode" + | null = null, +): string { + return buildSummaryPreviewTextBase( + settings, + ui, + resolveMenuLayoutMode, + focus, + ); +} + +function buildAccountListPreview( + settings: DashboardDisplaySettings, + ui: ReturnType, + focus: + | DashboardDisplaySettingKey + | DashboardStatuslineField + | "menuSortMode" + | "menuLayoutMode" + | null = null, +): { label: string; hint: string } { + return buildAccountListPreviewBase( + settings, + ui, + resolveMenuLayoutMode, + focus, + ); +} + function clampBackendNumber( option: BackendNumberSettingOption, value: number, @@ -589,24 +478,23 @@ async function promptDashboardDisplaySettings( async function configureDashboardDisplaySettings( currentSettings?: DashboardDisplaySettings, ): Promise { - const current = currentSettings ?? (await loadDashboardDisplaySettings()); - if (!input.isTTY || !output.isTTY) { - console.log("Settings require interactive mode."); - console.log(`Settings file: ${getDashboardSettingsPath()}`); - return current; - } - - const selected = await promptDashboardDisplaySettings(current); - if (!selected) return current; - if (dashboardSettingsEqual(current, selected)) return current; - - const merged = await persistDashboardSettingsSelection( - selected, - ACCOUNT_LIST_PANEL_KEYS, - "account-list", - ); - applyUiThemeFromDashboardSettings(merged); - return merged; + return configureDashboardSettingsController(currentSettings, { + loadDashboardDisplaySettings, + promptSettings: promptDashboardDisplaySettings, + settingsEqual: dashboardSettingsEqual, + persistSelection: (selected) => + persistDashboardSettingsSelection( + selected, + ACCOUNT_LIST_PANEL_KEYS, + "account-list", + ), + applyUiThemeFromDashboardSettings, + isInteractive: () => input.isTTY && output.isTTY, + getDashboardSettingsPath, + writeLine: (message) => { + console.log(message); + }, + }); } function reorderField( @@ -646,24 +534,23 @@ async function promptStatuslineSettings( async function configureStatuslineSettings( currentSettings?: DashboardDisplaySettings, ): Promise { - const current = currentSettings ?? (await loadDashboardDisplaySettings()); - if (!input.isTTY || !output.isTTY) { - console.log("Settings require interactive mode."); - console.log(`Settings file: ${getDashboardSettingsPath()}`); - return current; - } - - const selected = await promptStatuslineSettings(current); - if (!selected) return current; - if (dashboardSettingsEqual(current, selected)) return current; - - const merged = await persistDashboardSettingsSelection( - selected, - STATUSLINE_PANEL_KEYS, - "summary-fields", - ); - applyUiThemeFromDashboardSettings(merged); - return merged; + return configureDashboardSettingsController(currentSettings, { + loadDashboardDisplaySettings, + promptSettings: promptStatuslineSettings, + settingsEqual: dashboardSettingsEqual, + persistSelection: (selected) => + persistDashboardSettingsSelection( + selected, + STATUSLINE_PANEL_KEYS, + "summary-fields", + ), + applyUiThemeFromDashboardSettings, + isInteractive: () => input.isTTY && output.isTTY, + getDashboardSettingsPath, + writeLine: (message) => { + console.log(message); + }, + }); } function formatDelayLabel(delayMs: number): string { @@ -701,264 +588,35 @@ async function promptThemeSettings( }); } -function resolveFocusedBackendNumberKey( - focus: BackendSettingFocusKey, - numberOptions: BackendNumberSettingOption[] = BACKEND_NUMBER_OPTIONS, -): BackendNumberSettingKey { - const numberKeys = new Set( - numberOptions.map((option) => option.key), - ); - if (focus && numberKeys.has(focus as BackendNumberSettingKey)) { - return focus as BackendNumberSettingKey; - } - return numberOptions[0]?.key ?? "fetchTimeoutMs"; -} - -function getBackendCategory( - key: BackendCategoryKey, -): BackendCategoryOption | null { - return ( - BACKEND_CATEGORY_OPTIONS.find((category) => category.key === key) ?? null - ); -} - -function getBackendCategoryInitialFocus( - category: BackendCategoryOption, -): BackendSettingFocusKey { - const firstToggle = category.toggleKeys[0]; - if (firstToggle) return firstToggle; - return category.numberKeys[0] ?? null; -} - -function applyBackendCategoryDefaults( - draft: PluginConfig, - category: BackendCategoryOption, -): PluginConfig { - const next = { ...draft }; - for (const key of category.toggleKeys) { - next[key] = BACKEND_DEFAULTS[key] ?? false; - } - for (const key of category.numberKeys) { - const option = BACKEND_NUMBER_OPTION_BY_KEY.get(key); - const fallback = option?.min ?? 0; - next[key] = BACKEND_DEFAULTS[key] ?? fallback; - } - return next; -} - async function promptBackendCategorySettings( initial: PluginConfig, category: BackendCategoryOption, initialFocus: BackendSettingFocusKey, ): Promise<{ draft: PluginConfig; focusKey: BackendSettingFocusKey }> { - const ui = getUiRuntimeOptions(); - let draft = cloneBackendPluginConfig(initial); - let focusKey: BackendSettingFocusKey = initialFocus; - if ( - !focusKey || - (!category.toggleKeys.includes(focusKey as BackendToggleSettingKey) && - !category.numberKeys.includes(focusKey as BackendNumberSettingKey)) - ) { - focusKey = getBackendCategoryInitialFocus(category); - } - - const toggleOptions = category.toggleKeys - .map((key) => BACKEND_TOGGLE_OPTION_BY_KEY.get(key)) - .filter((option): option is BackendToggleSettingOption => !!option); - const numberOptions = category.numberKeys - .map((key) => BACKEND_NUMBER_OPTION_BY_KEY.get(key)) - .filter((option): option is BackendNumberSettingOption => !!option); - - while (true) { - const preview = buildBackendSettingsPreview(draft, ui, focusKey, { - highlightPreviewToken, - }); - const toggleItems: MenuItem[] = - toggleOptions.map((option, index) => { - const enabled = - draft[option.key] ?? BACKEND_DEFAULTS[option.key] ?? false; - return { - label: `${formatDashboardSettingState(enabled)} ${index + 1}. ${option.label}`, - hint: option.description, - value: { type: "toggle", key: option.key }, - color: enabled ? "green" : "yellow", - }; - }); - const numberItems: MenuItem[] = - numberOptions.map((option) => { - const rawValue = - draft[option.key] ?? BACKEND_DEFAULTS[option.key] ?? option.min; - const numericValue = - typeof rawValue === "number" && Number.isFinite(rawValue) - ? rawValue - : option.min; - const clampedValue = clampBackendNumber(option, numericValue); - const valueLabel = formatBackendNumberValue(option, clampedValue); - return { - label: `${option.label}: ${valueLabel}`, - hint: `${option.description} Step ${formatBackendNumberValue(option, option.step)}.`, - value: { type: "bump", key: option.key, direction: 1 }, - color: "yellow", - }; - }); - - const focusedNumberKey = resolveFocusedBackendNumberKey( - focusKey, - numberOptions, - ); - const items: MenuItem[] = [ - { - label: UI_COPY.settings.previewHeading, - value: { type: "back" }, - kind: "heading", - }, - { - label: preview.label, - hint: preview.hint, - value: { type: "back" }, - disabled: true, - color: "green", - hideUnavailableSuffix: true, - }, - { label: "", value: { type: "back" }, separator: true }, - { - label: UI_COPY.settings.backendToggleHeading, - value: { type: "back" }, - kind: "heading", - }, - ...toggleItems, - { label: "", value: { type: "back" }, separator: true }, - { - label: UI_COPY.settings.backendNumberHeading, - value: { type: "back" }, - kind: "heading", - }, - ...numberItems, - ]; - - if (numberOptions.length > 0) { - items.push({ label: "", value: { type: "back" }, separator: true }); - items.push({ - label: UI_COPY.settings.backendDecrease, - value: { type: "bump", key: focusedNumberKey, direction: -1 }, - color: "yellow", - }); - items.push({ - label: UI_COPY.settings.backendIncrease, - value: { type: "bump", key: focusedNumberKey, direction: 1 }, - color: "green", - }); - } - - items.push({ label: "", value: { type: "back" }, separator: true }); - items.push({ - label: UI_COPY.settings.backendResetCategory, - value: { type: "reset-category" }, - color: "yellow", - }); - items.push({ - label: UI_COPY.settings.backendBackToCategories, - value: { type: "back" }, - color: "red", - }); - - const initialCursor = items.findIndex((item) => { - if (item.separator || item.disabled || item.kind === "heading") - return false; - if (item.value.type === "toggle" && focusKey === item.value.key) - return true; - if (item.value.type === "bump" && focusKey === item.value.key) - return true; - return false; - }); - - const result = await select(items, { - message: `${UI_COPY.settings.backendCategoryTitle}: ${category.label}`, - subtitle: category.description, - help: UI_COPY.settings.backendCategoryHelp, - clearScreen: true, - theme: ui.theme, - selectedEmphasis: "minimal", - initialCursor: initialCursor >= 0 ? initialCursor : undefined, - onCursorChange: ({ cursor }) => { - const focusedItem = items[cursor]; - if ( - focusedItem?.value.type === "toggle" || - focusedItem?.value.type === "bump" - ) { - focusKey = focusedItem.value.key; - } - }, - onInput: (raw) => { - const lower = raw.toLowerCase(); - if (lower === "q") return { type: "back" }; - if (lower === "r") return { type: "reset-category" }; - if ( - numberOptions.length > 0 && - (lower === "+" || lower === "=" || lower === "]" || lower === "d") - ) { - return { - type: "bump", - key: resolveFocusedBackendNumberKey(focusKey, numberOptions), - direction: 1, - }; - } - if ( - numberOptions.length > 0 && - (lower === "-" || lower === "[" || lower === "a") - ) { - return { - type: "bump", - key: resolveFocusedBackendNumberKey(focusKey, numberOptions), - direction: -1, - }; - } - const parsed = Number.parseInt(raw, 10); - if ( - Number.isFinite(parsed) && - parsed >= 1 && - parsed <= toggleOptions.length - ) { - const target = toggleOptions[parsed - 1]; - if (target) return { type: "toggle", key: target.key }; - } - return undefined; - }, - }); - - if (!result || result.type === "back") { - return { draft, focusKey }; - } - if (result.type === "reset-category") { - draft = applyBackendCategoryDefaults(draft, category); - focusKey = getBackendCategoryInitialFocus(category); - continue; - } - if (result.type === "toggle") { - const currentValue = - draft[result.key] ?? BACKEND_DEFAULTS[result.key] ?? false; - draft = { ...draft, [result.key]: !currentValue }; - focusKey = result.key; - continue; - } - - const option = BACKEND_NUMBER_OPTION_BY_KEY.get(result.key); - if (!option) continue; - const currentValue = - draft[result.key] ?? BACKEND_DEFAULTS[result.key] ?? option.min; - const numericCurrent = - typeof currentValue === "number" && Number.isFinite(currentValue) - ? currentValue - : option.min; - draft = { - ...draft, - [result.key]: clampBackendNumber( - option, - numericCurrent + option.step * result.direction, - ), - }; - focusKey = result.key; - } + return promptBackendCategorySettingsMenu({ + initial, + category, + initialFocus, + ui: getUiRuntimeOptions(), + cloneBackendPluginConfig, + buildBackendSettingsPreview, + highlightPreviewToken, + resolveFocusedBackendNumberKey, + clampBackendNumber, + formatBackendNumberValue, + formatDashboardSettingState, + applyBackendCategoryDefaults: (config, selectedCategory) => + applyBackendCategoryDefaults(config, selectedCategory, { + backendDefaults: BACKEND_DEFAULTS, + numberOptionByKey: BACKEND_NUMBER_OPTION_BY_KEY, + }), + getBackendCategoryInitialFocus, + backendDefaults: BACKEND_DEFAULTS, + toggleOptionByKey: BACKEND_TOGGLE_OPTION_BY_KEY, + numberOptionByKey: BACKEND_NUMBER_OPTION_BY_KEY, + select, + copy: UI_COPY.settings, + }); } async function promptBackendSettings( @@ -1082,7 +740,7 @@ async function promptBackendSettings( continue; } - const category = getBackendCategory(result.key); + const category = getBackendCategory(result.key, BACKEND_CATEGORY_OPTIONS); if (!category) continue; activeCategory = category.key; const categoryResult = await promptBackendCategorySettings( @@ -1111,435 +769,129 @@ async function loadExperimentalSyncTarget(): Promise< destination: import("../storage.js").AccountStorageV3 | null; } > { - const detection = detectOcChatgptMultiAuthTarget(); - if (detection.kind === "ambiguous") { - return { kind: "blocked-ambiguous", detection }; - } - if (detection.kind === "none") { - return { kind: "blocked-none", detection }; - } - try { - const raw = JSON.parse( - await readFileWithRetry(detection.descriptor.accountPath, { - retryableCodes: new Set([ - "EBUSY", - "EPERM", - "EAGAIN", - "ENOTEMPTY", - "EACCES", - ]), - maxAttempts: 4, - sleep, - }), - ); - const normalized = normalizeAccountStorage(raw); - if (!normalized) { - return { - kind: "error", - message: "Invalid target account storage format", - }; - } - return { kind: "target", detection, destination: normalized }; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - return { kind: "target", detection, destination: null }; - } - return { - kind: "error", - message: error instanceof Error ? error.message : String(error), - }; - } + return loadExperimentalSyncTargetState({ + detectTarget: detectOcChatgptMultiAuthTarget, + readJson: async (path) => + JSON.parse( + await readFileWithRetry(path, { + retryableCodes: new Set([ + "EBUSY", + "EPERM", + "EAGAIN", + "ENOTEMPTY", + "EACCES", + ]), + maxAttempts: 4, + sleep, + }), + ), + normalizeAccountStorage, + }); } async function promptExperimentalSettings( initialConfig: PluginConfig, ): Promise { - if (!input.isTTY || !output.isTTY) return null; - const ui = getUiRuntimeOptions(); - let draft = cloneBackendPluginConfig(initialConfig); - while (true) { - const action = await select( - [ - { - label: UI_COPY.settings.experimentalSync, - value: { type: "sync" }, - color: "yellow", - }, - { - label: UI_COPY.settings.experimentalBackup, - value: { type: "backup" }, - color: "green", - }, - { - label: `${formatDashboardSettingState(draft.proactiveRefreshGuardian ?? false)} ${UI_COPY.settings.experimentalRefreshGuard}`, - value: { type: "toggle-refresh-guardian" }, - color: "yellow", - }, - { - label: `${UI_COPY.settings.experimentalRefreshInterval}: ${Math.round((draft.proactiveRefreshIntervalMs ?? 60000) / 60000)} min`, - value: { type: "back" }, - disabled: true, - hideUnavailableSuffix: true, - color: "green", - }, - { - label: UI_COPY.settings.experimentalDecreaseInterval, - value: { type: "decrease-refresh-interval" }, - color: "yellow", - }, - { - label: UI_COPY.settings.experimentalIncreaseInterval, - value: { type: "increase-refresh-interval" }, - color: "green", - }, - { - label: UI_COPY.settings.saveAndBack, - value: { type: "save" }, - color: "green", - }, - { - label: UI_COPY.settings.backNoSave, - value: { type: "back" }, - color: "red", - }, - ], - getExperimentalSelectOptions( - ui, - UI_COPY.settings.experimentalHelpMenu, - mapExperimentalMenuHotkey, - ), - ); - if (!action || action.type === "back") return null; - if (action.type === "save") return draft; - if (action.type === "toggle-refresh-guardian") { - draft = { - ...draft, - proactiveRefreshGuardian: !(draft.proactiveRefreshGuardian ?? false), + return promptExperimentalSettingsMenu({ + initialConfig, + isInteractive: () => input.isTTY && output.isTTY, + ui: getUiRuntimeOptions(), + cloneBackendPluginConfig, + select: select as never, + getExperimentalSelectOptions: getExperimentalSelectOptions as never, + mapExperimentalMenuHotkey: mapExperimentalMenuHotkey as never, + mapExperimentalStatusHotkey: mapExperimentalStatusHotkey as never, + formatDashboardSettingState, + copy: UI_COPY.settings, + input, + output, + runNamedBackupExport, + loadAccounts, + loadExperimentalSyncTarget, + planOcChatgptSync: planOcChatgptSync as never, + applyOcChatgptSync: applyOcChatgptSync as never, + getTargetKind: (targetState) => (targetState as { kind: string }).kind, + getTargetDestination: (targetState) => + (targetState as { kind: string; destination?: unknown }).destination, + getTargetDetection: (targetState) => + (targetState as { detection?: unknown }).detection, + getTargetErrorMessage: (targetState) => + (targetState as { kind: string; message?: string }).kind === "error" + ? ((targetState as { message?: string }).message ?? "Unknown error") + : null, + getPlanKind: (plan) => (plan as { kind: string }).kind, + getPlanBlockedReason: (plan) => { + const candidate = plan as { + kind: string; + detection?: { reason?: string }; }; - continue; - } - if (action.type === "decrease-refresh-interval") { - draft = { - ...draft, - proactiveRefreshIntervalMs: Math.max( - 60_000, - (draft.proactiveRefreshIntervalMs ?? 60000) - 60000, - ), + return candidate.kind === "blocked-ambiguous" + ? `Sync blocked: ${candidate.detection?.reason ?? "unknown"}` + : `Sync unavailable: ${candidate.detection?.reason ?? "unknown"}`; + }, + getPlanPreview: (plan) => + ( + plan as { + preview: { + toAdd: unknown[]; + toUpdate: unknown[]; + toSkip: unknown[]; + unchangedDestinationOnly: unknown[]; + activeSelectionBehavior: string; + }; + } + ).preview, + getAppliedLabel: (applied) => { + const candidate = applied as { + kind: string; + target?: { accountPath?: string }; + error?: unknown; }; - continue; - } - if (action.type === "increase-refresh-interval") { - draft = { - ...draft, - proactiveRefreshIntervalMs: Math.min( - 600000, - (draft.proactiveRefreshIntervalMs ?? 60000) + 60000, - ), + return { + label: + candidate.kind === "applied" + ? `Applied sync to ${candidate.target?.accountPath ?? "target"}` + : candidate.kind === "error" + ? candidate.error instanceof Error + ? candidate.error.message + : String(candidate.error) + : "Sync did not apply", + color: candidate.kind === "applied" ? "green" : "yellow", }; - continue; - } - if (action.type === "backup") { - const prompt = createInterface({ input, output }); - try { - const backupName = ( - await prompt.question(UI_COPY.settings.experimentalBackupPrompt) - ).trim(); - if (!backupName || backupName.toLowerCase() === "q") { - continue; - } - try { - const backupResult = await runNamedBackupExport({ name: backupName }); - const backupLabel = - backupResult.kind === "exported" - ? `Saved backup to ${backupResult.path}` - : backupResult.kind === "collision" - ? `Backup already exists: ${backupResult.path}` - : backupResult.error instanceof Error - ? backupResult.error.message - : String(backupResult.error); - await select( - [ - { - label: backupLabel, - value: { type: "back" }, - disabled: true, - hideUnavailableSuffix: true, - color: backupResult.kind === "exported" ? "green" : "yellow", - }, - { - label: UI_COPY.settings.back, - value: { type: "back" }, - color: "red", - }, - ], - getExperimentalSelectOptions( - ui, - UI_COPY.settings.experimentalHelpStatus, - mapExperimentalStatusHotkey, - ), - ); - } catch (error) { - const message = - error instanceof Error ? error.message : String(error); - await select( - [ - { - label: message, - value: { type: "back" }, - disabled: true, - hideUnavailableSuffix: true, - color: "yellow", - }, - { - label: UI_COPY.settings.back, - value: { type: "back" }, - color: "red", - }, - ], - getExperimentalSelectOptions( - ui, - UI_COPY.settings.experimentalHelpStatus, - mapExperimentalStatusHotkey, - ), - ); - } - } finally { - prompt.close(); - } - continue; - } - - const source = await loadAccounts(); - const targetState = await loadExperimentalSyncTarget(); - if (targetState.kind === "error") { - await select( - [ - { - label: targetState.message, - value: { type: "back" }, - disabled: true, - hideUnavailableSuffix: true, - color: "yellow", - }, - { - label: UI_COPY.settings.back, - value: { type: "back" }, - color: "red", - }, - ], - getExperimentalSelectOptions( - ui, - UI_COPY.settings.experimentalHelpStatus, - mapExperimentalStatusHotkey, - ), - ); - continue; - } - const plan = await planOcChatgptSync({ - source, - destination: - targetState.kind === "target" ? targetState.destination : null, - dependencies: - targetState.kind === "target" - ? { detectTarget: () => targetState.detection } - : undefined, - }); - if (plan.kind !== "ready") { - await select( - [ - { - label: - plan.kind === "blocked-ambiguous" - ? `Sync blocked: ${plan.detection.reason}` - : `Sync unavailable: ${plan.detection.reason}`, - value: { type: "back" }, - disabled: true, - hideUnavailableSuffix: true, - color: "yellow", - }, - { - label: UI_COPY.settings.back, - value: { type: "back" }, - color: "red", - }, - ], - getExperimentalSelectOptions( - ui, - UI_COPY.settings.experimentalHelpStatus, - mapExperimentalStatusHotkey, - ), - ); - continue; - } - - const review = await select( - [ - { - label: `Preview: add ${plan.preview.toAdd.length} | update ${plan.preview.toUpdate.length} | skip ${plan.preview.toSkip.length}`, - value: { type: "back" }, - disabled: true, - hideUnavailableSuffix: true, - color: "green", - }, - { - label: `Preserve destination-only: ${plan.preview.unchangedDestinationOnly.length}`, - value: { type: "back" }, - disabled: true, - hideUnavailableSuffix: true, - color: "green", - }, - { - label: `Active selection: ${plan.preview.activeSelectionBehavior}`, - value: { type: "back" }, - disabled: true, - hideUnavailableSuffix: true, - color: "green", - }, - { - label: UI_COPY.settings.experimentalApplySync, - value: { type: "apply" }, - color: "green", - }, - { - label: UI_COPY.settings.backNoSave, - value: { type: "back" }, - color: "red", - }, - ], - getExperimentalSelectOptions( - ui, - UI_COPY.settings.experimentalHelpPreview, - (raw) => { - const lower = raw.toLowerCase(); - if (lower === "q") return { type: "back" }; - if (lower === "a") return { type: "apply" }; - return undefined; - }, - ), - ); - if (!review || review.type === "back") continue; - - const applied = await applyOcChatgptSync({ - source, - destination: - targetState.kind === "target" ? targetState.destination : undefined, - dependencies: - targetState.kind === "target" - ? { detectTarget: () => targetState.detection } - : undefined, - }); - await select( - [ - { - label: - applied.kind === "applied" - ? `Applied sync to ${applied.target.accountPath}` - : applied.kind === "error" - ? applied.error instanceof Error - ? applied.error.message - : String(applied.error) - : "Sync did not apply", - value: { type: "back" }, - disabled: true, - hideUnavailableSuffix: true, - color: applied.kind === "applied" ? "green" : "yellow", - }, - { label: UI_COPY.settings.back, value: { type: "back" }, color: "red" }, - ], - getExperimentalSelectOptions( - ui, - UI_COPY.settings.experimentalHelpStatus, - mapExperimentalStatusHotkey, - ), - ); - } + }, + }); } async function configureBackendSettings( currentConfig?: PluginConfig, ): Promise { - const current = cloneBackendPluginConfig(currentConfig ?? loadPluginConfig()); - if (!input.isTTY || !output.isTTY) { - console.log("Settings require interactive mode."); - return current; - } - - const selected = await promptBackendSettings(current); - if (!selected) return current; - if (backendSettingsEqual(current, selected)) return current; - - return persistBackendConfigSelection(selected, "backend"); + return configureBackendSettingsEntry(currentConfig, { + configureBackendSettingsController, + cloneBackendPluginConfig, + loadPluginConfig, + promptBackendSettings, + backendSettingsEqual, + persistBackendConfigSelection, + isInteractive: () => input.isTTY && output.isTTY, + writeLine: (message) => { + console.log(message); + }, + }); } async function promptSettingsHub( initialFocus: SettingsHubAction["type"] = "account-list", ): Promise { - if (!input.isTTY || !output.isTTY) return null; - const ui = getUiRuntimeOptions(); - const items: MenuItem[] = [ - { - label: UI_COPY.settings.sectionTitle, - value: { type: "back" }, - kind: "heading", - }, - { - label: UI_COPY.settings.accountList, - value: { type: "account-list" }, - color: "green", - }, - { - label: UI_COPY.settings.summaryFields, - value: { type: "summary-fields" }, - color: "green", - }, - { - label: UI_COPY.settings.behavior, - value: { type: "behavior" }, - color: "green", - }, - { label: UI_COPY.settings.theme, value: { type: "theme" }, color: "green" }, - { label: "", value: { type: "back" }, separator: true }, - { - label: UI_COPY.settings.advancedTitle, - value: { type: "back" }, - kind: "heading", - }, - { - label: UI_COPY.settings.experimental, - value: { type: "experimental" }, - color: "yellow", - }, - { - label: UI_COPY.settings.backend, - value: { type: "backend" }, - color: "green", - }, - { label: "", value: { type: "back" }, separator: true }, - { - label: UI_COPY.settings.exitTitle, - value: { type: "back" }, - kind: "heading", - }, - { label: UI_COPY.settings.back, value: { type: "back" }, color: "red" }, - ]; - const initialCursor = items.findIndex((item) => { - if (item.separator || item.disabled || item.kind === "heading") - return false; - return item.value.type === initialFocus; - }); - return select(items, { - message: UI_COPY.settings.title, - subtitle: UI_COPY.settings.subtitle, - help: UI_COPY.settings.help, - clearScreen: true, - theme: ui.theme, - selectedEmphasis: "minimal", - initialCursor: initialCursor >= 0 ? initialCursor : undefined, - onInput: (raw) => { - const lower = raw.toLowerCase(); - if (lower === "q") return { type: "back" }; - return undefined; + return promptSettingsHubMenu(initialFocus, { + isInteractive: () => input.isTTY && output.isTTY, + getUiRuntimeOptions, + buildItems: () => buildSettingsHubItems(UI_COPY.settings), + findInitialCursor: findSettingsHubInitialCursor, + select, + copy: { + title: UI_COPY.settings.title, + subtitle: UI_COPY.settings.subtitle, + help: UI_COPY.settings.help, }, }); } @@ -1549,65 +901,27 @@ async function promptSettingsHub( async function configureUnifiedSettings( initialSettings?: DashboardDisplaySettings, ): Promise { - let current = cloneDashboardSettings( - initialSettings ?? (await loadDashboardDisplaySettings()), - ); - let backendConfig = cloneBackendPluginConfig(loadPluginConfig()); - applyUiThemeFromDashboardSettings(current); - let hubFocus: SettingsHubAction["type"] = "account-list"; - while (true) { - const action = await promptSettingsHub(hubFocus); - if (!action || action.type === "back") { - return current; - } - hubFocus = action.type; - if (action.type === "account-list") { - current = await configureDashboardDisplaySettings(current); - continue; - } - if (action.type === "summary-fields") { - current = await configureStatuslineSettings(current); - continue; - } - if (action.type === "behavior") { - const selected = await promptBehaviorSettings(current); - if (selected && !dashboardSettingsEqual(current, selected)) { - current = await persistDashboardSettingsSelection( - selected, - BEHAVIOR_PANEL_KEYS, - "behavior", - ); - } - continue; - } - if (action.type === "theme") { - const selected = await promptThemeSettings(current); - if (selected && !dashboardSettingsEqual(current, selected)) { - current = await persistDashboardSettingsSelection( - selected, - THEME_PANEL_KEYS, - "theme", - ); - applyUiThemeFromDashboardSettings(current); - } - continue; - } - if (action.type === "experimental") { - const selected = await promptExperimentalSettings(backendConfig); - if (selected && !backendSettingsEqual(backendConfig, selected)) { - backendConfig = await persistBackendConfigSelection( - selected, - "experimental", - ); - } else if (selected) { - backendConfig = selected; - } - continue; - } - if (action.type === "backend") { - backendConfig = await configureBackendSettings(backendConfig); - } - } + return configureUnifiedSettingsController(initialSettings, { + cloneDashboardSettings, + cloneBackendPluginConfig, + loadDashboardDisplaySettings, + loadPluginConfig, + applyUiThemeFromDashboardSettings, + promptSettingsHub: async (focus) => + promptSettingsHub(focus as SettingsHubActionType), + configureDashboardDisplaySettings, + configureStatuslineSettings, + promptBehaviorSettings, + promptThemeSettings, + dashboardSettingsEqual, + persistDashboardSettingsSelection, + promptExperimentalSettings, + backendSettingsEqual, + persistBackendConfigSelection, + configureBackendSettings, + BEHAVIOR_PANEL_KEYS, + THEME_PANEL_KEYS, + }); } export { diff --git a/lib/codex-manager/settings-preview.ts b/lib/codex-manager/settings-preview.ts new file mode 100644 index 00000000..308bc573 --- /dev/null +++ b/lib/codex-manager/settings-preview.ts @@ -0,0 +1,192 @@ +import { stdout as output } from "node:process"; +import type { + DashboardDisplaySettings, + DashboardStatuslineField, +} from "../dashboard-settings.js"; +import { ANSI } from "../ui/ansi.js"; +import type { UiRuntimeOptions } from "../ui/runtime.js"; + +export const DEFAULT_STATUSLINE_FIELDS: DashboardStatuslineField[] = [ + "last-used", + "limits", + "status", +]; + +const PREVIEW_ACCOUNT_EMAIL = "demo@example.com"; +const PREVIEW_LAST_USED = "today"; +const PREVIEW_STATUS = "active"; +const PREVIEW_LIMITS = "5h ██████▒▒▒▒ 62% | 7d █████▒▒▒▒▒ 49%"; +const PREVIEW_LIMIT_COOLDOWNS = "5h reset 1h 20m | 7d reset 2d 04h"; + +export type PreviewFocusKey = + | DashboardStatuslineField + | "menuShowStatusBadge" + | "menuShowCurrentBadge" + | "menuShowLastUsed" + | "menuShowQuotaSummary" + | "menuShowQuotaCooldown" + | "menuShowFetchStatus" + | "menuShowDetailsForUnselectedRows" + | "menuHighlightCurrentRow" + | "menuSortEnabled" + | "menuSortPinCurrent" + | "menuSortQuickSwitchVisibleRow" + | "menuSortMode" + | "menuLayoutMode" + | null; + +export function highlightPreviewToken( + text: string, + ui: UiRuntimeOptions, +): string { + if (!output.isTTY) return text; + if (ui.v2Enabled) { + return `${ui.theme.colors.accent}${ANSI.bold}${text}${ui.theme.colors.reset}`; + } + return `${ANSI.cyan}${ANSI.bold}${text}${ANSI.reset}`; +} + +function isLastUsedPreviewFocus(focus: PreviewFocusKey): boolean { + return focus === "menuShowLastUsed" || focus === "last-used"; +} + +function isLimitsPreviewFocus(focus: PreviewFocusKey): boolean { + return focus === "menuShowQuotaSummary" || focus === "limits"; +} + +function isLimitsCooldownPreviewFocus(focus: PreviewFocusKey): boolean { + return focus === "menuShowQuotaCooldown"; +} + +function isStatusPreviewFocus(focus: PreviewFocusKey): boolean { + return focus === "menuShowStatusBadge" || focus === "status"; +} + +function isCurrentBadgePreviewFocus(focus: PreviewFocusKey): boolean { + return focus === "menuShowCurrentBadge"; +} + +function isCurrentRowPreviewFocus(focus: PreviewFocusKey): boolean { + return focus === "menuHighlightCurrentRow"; +} + +function isExpandedRowsPreviewFocus(focus: PreviewFocusKey): boolean { + return ( + focus === "menuShowDetailsForUnselectedRows" || focus === "menuLayoutMode" + ); +} + +export function normalizeStatuslineFields( + fields: DashboardStatuslineField[] | undefined, +): DashboardStatuslineField[] { + const source = fields ?? DEFAULT_STATUSLINE_FIELDS; + const seen = new Set(); + const normalized: DashboardStatuslineField[] = []; + for (const field of source) { + if (seen.has(field)) continue; + seen.add(field); + normalized.push(field); + } + if (normalized.length === 0) { + return [...DEFAULT_STATUSLINE_FIELDS]; + } + return normalized; +} + +export function buildSummaryPreviewText( + settings: DashboardDisplaySettings, + ui: UiRuntimeOptions, + resolveMenuLayoutMode: ( + settings: DashboardDisplaySettings, + ) => "compact-details" | "expanded-rows", + focus: PreviewFocusKey = null, +): string { + const partsByField = new Map(); + if (settings.menuShowLastUsed !== false) { + const part = `last used: ${PREVIEW_LAST_USED}`; + partsByField.set( + "last-used", + isLastUsedPreviewFocus(focus) ? highlightPreviewToken(part, ui) : part, + ); + } + if (settings.menuShowQuotaSummary !== false) { + const limitsText = + settings.menuShowQuotaCooldown === false + ? PREVIEW_LIMITS + : `${PREVIEW_LIMITS} | ${PREVIEW_LIMIT_COOLDOWNS}`; + const part = `limits: ${limitsText}`; + partsByField.set( + "limits", + isLimitsPreviewFocus(focus) || isLimitsCooldownPreviewFocus(focus) + ? highlightPreviewToken(part, ui) + : part, + ); + } + if (settings.menuShowStatusBadge === false) { + const part = `status: ${PREVIEW_STATUS}`; + partsByField.set( + "status", + isStatusPreviewFocus(focus) ? highlightPreviewToken(part, ui) : part, + ); + } + + const orderedParts = normalizeStatuslineFields(settings.menuStatuslineFields) + .map((field) => partsByField.get(field)) + .filter( + (part): part is string => typeof part === "string" && part.length > 0, + ); + if (orderedParts.length > 0) { + return orderedParts.join(" | "); + } + + const showsStatusField = normalizeStatuslineFields( + settings.menuStatuslineFields, + ).includes("status"); + if (showsStatusField && settings.menuShowStatusBadge !== false) { + const note = "status text appears only when status badges are hidden"; + return isStatusPreviewFocus(focus) ? highlightPreviewToken(note, ui) : note; + } + return "no summary text is visible with current account-list settings"; +} + +export function buildAccountListPreview( + settings: DashboardDisplaySettings, + ui: UiRuntimeOptions, + resolveMenuLayoutMode: ( + settings: DashboardDisplaySettings, + ) => "compact-details" | "expanded-rows", + focus: PreviewFocusKey = null, +): { label: string; hint: string } { + const badges: string[] = []; + if (settings.menuShowCurrentBadge !== false) { + const currentBadge = "[current]"; + badges.push( + isCurrentBadgePreviewFocus(focus) + ? highlightPreviewToken(currentBadge, ui) + : currentBadge, + ); + } + if (settings.menuShowStatusBadge !== false) { + const statusBadge = "[active]"; + badges.push( + isStatusPreviewFocus(focus) + ? highlightPreviewToken(statusBadge, ui) + : statusBadge, + ); + } + const badgeSuffix = badges.length > 0 ? ` ${badges.join(" ")}` : ""; + const accountEmail = isCurrentRowPreviewFocus(focus) + ? highlightPreviewToken(PREVIEW_ACCOUNT_EMAIL, ui) + : PREVIEW_ACCOUNT_EMAIL; + const rowDetailMode = + resolveMenuLayoutMode(settings) === "expanded-rows" + ? "details shown on all rows" + : "details shown on selected row only"; + const detailModeText = isExpandedRowsPreviewFocus(focus) + ? highlightPreviewToken(rowDetailMode, ui) + : rowDetailMode; + return { + label: `1. ${accountEmail}${badgeSuffix}`, + hint: `${buildSummaryPreviewText(settings, ui, resolveMenuLayoutMode, focus)}\n${detailModeText}`, + }; +} diff --git a/lib/codex-manager/unified-settings-controller.ts b/lib/codex-manager/unified-settings-controller.ts new file mode 100644 index 00000000..21cb6e6c --- /dev/null +++ b/lib/codex-manager/unified-settings-controller.ts @@ -0,0 +1,123 @@ +import type { DashboardDisplaySettings } from "../dashboard-settings.js"; +import type { PluginConfig } from "../types.js"; + +export type SettingsHubActionType = + | "account-list" + | "summary-fields" + | "behavior" + | "theme" + | "experimental" + | "backend" + | "back"; + +export async function configureUnifiedSettingsController( + initialSettings: DashboardDisplaySettings | undefined, + deps: { + cloneDashboardSettings: ( + settings: DashboardDisplaySettings, + ) => DashboardDisplaySettings; + cloneBackendPluginConfig: (config: PluginConfig) => PluginConfig; + loadDashboardDisplaySettings: () => Promise; + loadPluginConfig: () => PluginConfig; + applyUiThemeFromDashboardSettings: ( + settings: DashboardDisplaySettings, + ) => void; + promptSettingsHub: ( + focus: SettingsHubActionType, + ) => Promise<{ type: SettingsHubActionType } | null>; + configureDashboardDisplaySettings: ( + current: DashboardDisplaySettings, + ) => Promise; + configureStatuslineSettings: ( + current: DashboardDisplaySettings, + ) => Promise; + promptBehaviorSettings: ( + current: DashboardDisplaySettings, + ) => Promise; + promptThemeSettings: ( + current: DashboardDisplaySettings, + ) => Promise; + dashboardSettingsEqual: ( + left: DashboardDisplaySettings, + right: DashboardDisplaySettings, + ) => boolean; + persistDashboardSettingsSelection: ( + selected: DashboardDisplaySettings, + keys: readonly (keyof DashboardDisplaySettings)[], + scope: string, + ) => Promise; + promptExperimentalSettings: ( + config: PluginConfig, + ) => Promise; + backendSettingsEqual: (left: PluginConfig, right: PluginConfig) => boolean; + persistBackendConfigSelection: ( + config: PluginConfig, + scope: string, + ) => Promise; + configureBackendSettings: (config: PluginConfig) => Promise; + BEHAVIOR_PANEL_KEYS: readonly (keyof DashboardDisplaySettings)[]; + THEME_PANEL_KEYS: readonly (keyof DashboardDisplaySettings)[]; + }, +): Promise { + let current = deps.cloneDashboardSettings( + initialSettings ?? (await deps.loadDashboardDisplaySettings()), + ); + let backendConfig = deps.cloneBackendPluginConfig(deps.loadPluginConfig()); + deps.applyUiThemeFromDashboardSettings(current); + let hubFocus: SettingsHubActionType = "account-list"; + + while (true) { + const action = await deps.promptSettingsHub(hubFocus); + if (!action || action.type === "back") { + return current; + } + hubFocus = action.type; + + if (action.type === "account-list") { + current = await deps.configureDashboardDisplaySettings(current); + continue; + } + if (action.type === "summary-fields") { + current = await deps.configureStatuslineSettings(current); + continue; + } + if (action.type === "behavior") { + const selected = await deps.promptBehaviorSettings(current); + if (selected && !deps.dashboardSettingsEqual(current, selected)) { + current = await deps.persistDashboardSettingsSelection( + selected, + deps.BEHAVIOR_PANEL_KEYS, + "behavior", + ); + } + continue; + } + if (action.type === "theme") { + const selected = await deps.promptThemeSettings(current); + if (selected && !deps.dashboardSettingsEqual(current, selected)) { + current = await deps.persistDashboardSettingsSelection( + selected, + deps.THEME_PANEL_KEYS, + "theme", + ); + deps.applyUiThemeFromDashboardSettings(current); + } + continue; + } + if (action.type === "experimental") { + const selected = await deps.promptExperimentalSettings(backendConfig); + if (selected && !deps.backendSettingsEqual(backendConfig, selected)) { + backendConfig = await deps.persistBackendConfigSelection( + selected, + "experimental", + ); + } else if (selected) { + backendConfig = selected; + } + continue; + } + if (action.type === "backend") { + backendConfig = await deps.configureBackendSettings(backendConfig); + } + } +} diff --git a/lib/request/failover-config.ts b/lib/request/failover-config.ts new file mode 100644 index 00000000..f491e12e --- /dev/null +++ b/lib/request/failover-config.ts @@ -0,0 +1,14 @@ +import type { FailoverMode } from "./failure-policy.js"; + +export function parseFailoverMode(value: string | undefined): FailoverMode { + const normalized = (value ?? "").trim().toLowerCase(); + if (normalized === "aggressive") return "aggressive"; + if (normalized === "conservative") return "conservative"; + return "balanced"; +} + +export function parseEnvInt(value: string | undefined): number | undefined { + if (value === undefined) return undefined; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : undefined; +} diff --git a/lib/request/request-init.ts b/lib/request/request-init.ts new file mode 100644 index 00000000..6c3fa282 --- /dev/null +++ b/lib/request/request-init.ts @@ -0,0 +1,72 @@ +export async function normalizeRequestInit( + requestInput: Request | string | URL, + requestInit: RequestInit | undefined, +): Promise { + if (requestInit) return requestInit; + if (!(requestInput instanceof Request)) return requestInit; + + const method = requestInput.method || "GET"; + const normalized: RequestInit = { + method, + headers: new Headers(requestInput.headers), + }; + + if (method !== "GET" && method !== "HEAD") { + try { + const bodyText = await requestInput.clone().text(); + if (bodyText) { + normalized.body = bodyText; + } + } catch { + // Body may be unreadable; proceed without it. + } + } + + return normalized; +} + +export async function parseRequestBodyFromInit( + body: unknown, + logWarn: (message: string) => void, +): Promise> { + if (!body) return {}; + + try { + if (typeof body === "string") { + return JSON.parse(body) as Record; + } + + if (body instanceof Uint8Array) { + return JSON.parse(new TextDecoder().decode(body)) as Record< + string, + unknown + >; + } + + if (body instanceof ArrayBuffer) { + return JSON.parse( + new TextDecoder().decode(new Uint8Array(body)), + ) as Record; + } + + if (ArrayBuffer.isView(body)) { + const view = new Uint8Array( + body.buffer, + body.byteOffset, + body.byteLength, + ); + return JSON.parse(new TextDecoder().decode(view)) as Record< + string, + unknown + >; + } + + if (typeof Blob !== "undefined" && body instanceof Blob) { + return JSON.parse(await body.text()) as Record; + } + } catch { + logWarn("Failed to parse request body, using empty object"); + } + + return {}; +} diff --git a/lib/request/response-metadata.ts b/lib/request/response-metadata.ts new file mode 100644 index 00000000..d4fd7cc5 --- /dev/null +++ b/lib/request/response-metadata.ts @@ -0,0 +1,65 @@ +const MAX_RETRY_HINT_MS = 5 * 60 * 1000; + +function clampRetryHintMs(value: number): number | null { + if (!Number.isFinite(value)) return null; + const normalized = Math.floor(value); + if (normalized <= 0) return null; + return Math.min(normalized, MAX_RETRY_HINT_MS); +} + +export function parseRetryAfterHintMs(headers: Headers): number | null { + const retryAfterMsHeader = headers.get("retry-after-ms")?.trim(); + if (retryAfterMsHeader && /^\d+$/.test(retryAfterMsHeader)) { + return clampRetryHintMs(Number.parseInt(retryAfterMsHeader, 10)); + } + + const retryAfterHeader = headers.get("retry-after")?.trim(); + if (retryAfterHeader && /^\d+$/.test(retryAfterHeader)) { + return clampRetryHintMs(Number.parseInt(retryAfterHeader, 10) * 1000); + } + if (retryAfterHeader) { + const retryAtMs = Date.parse(retryAfterHeader); + if (Number.isFinite(retryAtMs)) { + return clampRetryHintMs(retryAtMs - Date.now()); + } + } + + const resetAtHeader = headers.get("x-ratelimit-reset")?.trim(); + if (resetAtHeader && /^\d+$/.test(resetAtHeader)) { + const resetRaw = Number.parseInt(resetAtHeader, 10); + const resetAtMs = resetRaw < 10_000_000_000 ? resetRaw * 1000 : resetRaw; + return clampRetryHintMs(resetAtMs - Date.now()); + } + + return null; +} + +export function sanitizeResponseHeadersForLog( + headers: Headers, +): Record { + const allowed = new Set([ + "content-type", + "x-request-id", + "x-openai-request-id", + "x-codex-plan-type", + "x-codex-active-limit", + "x-codex-primary-used-percent", + "x-codex-primary-window-minutes", + "x-codex-primary-reset-at", + "x-codex-primary-reset-after-seconds", + "x-codex-secondary-used-percent", + "x-codex-secondary-window-minutes", + "x-codex-secondary-reset-at", + "x-codex-secondary-reset-after-seconds", + "retry-after", + "x-ratelimit-reset", + "x-ratelimit-reset-requests", + ]); + const sanitized: Record = {}; + for (const [rawName, rawValue] of headers.entries()) { + const name = rawName.toLowerCase(); + if (!allowed.has(name)) continue; + sanitized[name] = rawValue; + } + return sanitized; +} diff --git a/lib/request/wait-utils.ts b/lib/request/wait-utils.ts new file mode 100644 index 00000000..b9f2f80b --- /dev/null +++ b/lib/request/wait-utils.ts @@ -0,0 +1,70 @@ +export function createAbortableSleep( + abortSignal?: AbortSignal | null, +): (ms: number) => Promise { + return (ms: number): Promise => + new Promise((resolve, reject) => { + if (abortSignal?.aborted) { + reject(new Error("Aborted")); + return; + } + + const timeout = setTimeout(() => { + cleanup(); + resolve(); + }, ms); + + const onAbort = () => { + cleanup(); + reject(new Error("Aborted")); + }; + + const cleanup = () => { + clearTimeout(timeout); + abortSignal?.removeEventListener("abort", onAbort); + }; + + abortSignal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +export async function sleepWithCountdown(params: { + totalMs: number; + message: string; + sleep: (ms: number) => Promise; + showToast: ( + message: string, + variant: "warning", + options: { duration: number }, + ) => Promise; + formatWaitTime: (ms: number) => string; + toastDurationMs: number; + abortSignal?: AbortSignal | null; + intervalMs?: number; +}): Promise { + const startTime = Date.now(); + const endTime = startTime + params.totalMs; + const intervalMs = params.intervalMs ?? 5000; + + while (Date.now() < endTime) { + if (params.abortSignal?.aborted) { + throw new Error("Aborted"); + } + + const remaining = Math.max(0, endTime - Date.now()); + const waitLabel = params.formatWaitTime(remaining); + await params.showToast( + `${params.message} (${waitLabel} remaining)`, + "warning", + { + duration: Math.min(intervalMs + 1000, params.toastDurationMs), + }, + ); + + const sleepTime = Math.min(intervalMs, remaining); + if (sleepTime > 0) { + await params.sleep(sleepTime); + } else { + break; + } + } +} diff --git a/lib/runtime/account-check-helpers.ts b/lib/runtime/account-check-helpers.ts new file mode 100644 index 00000000..7befd1d5 --- /dev/null +++ b/lib/runtime/account-check-helpers.ts @@ -0,0 +1,42 @@ +import type { ModelFamily } from "../prompts/codex.js"; +import type { AccountStorageV3 } from "../storage.js"; +import type { TokenResult } from "../types.js"; + +export function clampActiveIndices( + storage: AccountStorageV3, + families: readonly ModelFamily[], +): void { + const count = storage.accounts.length; + if (count === 0) { + storage.activeIndex = 0; + storage.activeIndexByFamily = {}; + return; + } + storage.activeIndex = Math.max(0, Math.min(storage.activeIndex, count - 1)); + storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; + for (const family of families) { + const raw = storage.activeIndexByFamily[family]; + const candidate = + typeof raw === "number" && Number.isFinite(raw) + ? raw + : storage.activeIndex; + storage.activeIndexByFamily[family] = Math.max( + 0, + Math.min(candidate, count - 1), + ); + } +} + +export function isFlaggableFailure( + failure: Extract, +): boolean { + if (failure.reason === "missing_refresh") return true; + if (failure.statusCode === 401) return true; + if (failure.statusCode !== 400) return false; + const message = (failure.message ?? "").toLowerCase(); + return ( + message.includes("invalid_grant") || + message.includes("invalid refresh") || + message.includes("token has been revoked") + ); +} diff --git a/lib/runtime/account-pool.ts b/lib/runtime/account-pool.ts new file mode 100644 index 00000000..35ec7796 --- /dev/null +++ b/lib/runtime/account-pool.ts @@ -0,0 +1,205 @@ +import type { Workspace } from "../accounts.js"; +import type { ModelFamily } from "../prompts/codex.js"; +import type { AccountMetadataV3, AccountStorageV3 } from "../storage.js"; +import type { AccountIdSource, TokenResult } from "../types.js"; + +export type TokenSuccessWithAccount = Extract< + TokenResult, + { type: "success" } +> & { + accountIdOverride?: string; + accountIdSource?: AccountIdSource; + accountLabel?: string; + workspaces?: Workspace[]; +}; + +export async function persistAccountPoolResults(params: { + results: TokenSuccessWithAccount[]; + replaceAll?: boolean; + modelFamilies: readonly ModelFamily[]; + withAccountStorageTransaction: ( + handler: ( + loadedStorage: AccountStorageV3 | null, + persist: (storage: AccountStorageV3) => Promise, + ) => Promise, + ) => Promise; + findMatchingAccountIndex: typeof import("../storage.js").findMatchingAccountIndex; + extractAccountId: (accessToken: string) => string | undefined; + extractAccountEmail: ( + accessToken: string, + idToken?: string, + ) => string | undefined; + sanitizeEmail: (email: string | undefined) => string | undefined; +}): Promise { + const { results, replaceAll = false } = params; + if (results.length === 0) return; + + await params.withAccountStorageTransaction(async (loadedStorage, persist) => { + const now = Date.now(); + const stored = replaceAll ? null : loadedStorage; + const accounts = stored?.accounts ? [...stored.accounts] : []; + + for (const result of results) { + const accountId = + result.accountIdOverride ?? params.extractAccountId(result.access); + const accountIdSource = accountId + ? (result.accountIdSource ?? + (result.accountIdOverride ? "manual" : "token")) + : undefined; + const accountLabel = result.accountLabel; + const accountEmail = params.sanitizeEmail( + params.extractAccountEmail(result.access, result.idToken), + ); + const existingIndex = params.findMatchingAccountIndex( + accounts, + { + accountId, + email: accountEmail, + refreshToken: result.refresh, + }, + { + allowUniqueAccountIdFallbackWithoutEmail: true, + }, + ); + + if (existingIndex === undefined) { + const initialWorkspaceIndex = + result.workspaces && result.workspaces.length > 0 + ? (() => { + if (accountId) { + const matchingWorkspaceIndex = result.workspaces.findIndex( + (workspace) => workspace.id === accountId, + ); + if (matchingWorkspaceIndex >= 0) { + return matchingWorkspaceIndex; + } + } + const firstEnabledWorkspaceIndex = result.workspaces.findIndex( + (workspace) => workspace.enabled !== false, + ); + return firstEnabledWorkspaceIndex >= 0 + ? firstEnabledWorkspaceIndex + : 0; + })() + : undefined; + accounts.push({ + accountId, + accountIdSource, + accountLabel, + email: accountEmail, + refreshToken: result.refresh, + accessToken: result.access, + expiresAt: result.expires, + addedAt: now, + lastUsed: now, + workspaces: result.workspaces, + currentWorkspaceIndex: initialWorkspaceIndex, + }); + continue; + } + + const existing = accounts[existingIndex]; + if (!existing) continue; + + const nextEmail = accountEmail ?? params.sanitizeEmail(existing.email); + const nextAccountId = accountId ?? existing.accountId; + const nextAccountIdSource = accountId + ? (accountIdSource ?? existing.accountIdSource) + : existing.accountIdSource; + const nextAccountLabel = accountLabel ?? existing.accountLabel; + const mergedWorkspaces = result.workspaces + ? result.workspaces.map((newWs) => { + const existingWs = existing.workspaces?.find( + (w) => w.id === newWs.id, + ); + return existingWs + ? { + ...newWs, + enabled: existingWs.enabled, + disabledAt: existingWs.disabledAt, + } + : newWs; + }) + : existing.workspaces; + const currentWorkspaceId = + existing.workspaces?.[ + typeof existing.currentWorkspaceIndex === "number" + ? existing.currentWorkspaceIndex + : 0 + ]?.id; + const nextCurrentWorkspaceIndex = + mergedWorkspaces && mergedWorkspaces.length > 0 + ? (() => { + if (currentWorkspaceId) { + const matchingWorkspaceIndex = mergedWorkspaces.findIndex( + (workspace) => workspace.id === currentWorkspaceId, + ); + if (matchingWorkspaceIndex >= 0) { + return matchingWorkspaceIndex; + } + } + const defaultWorkspaceIndex = mergedWorkspaces.findIndex( + (workspace) => workspace.isDefault === true, + ); + if (defaultWorkspaceIndex >= 0) { + return defaultWorkspaceIndex; + } + const firstEnabledWorkspaceIndex = mergedWorkspaces.findIndex( + (workspace) => workspace.enabled !== false, + ); + return firstEnabledWorkspaceIndex >= 0 + ? firstEnabledWorkspaceIndex + : 0; + })() + : existing.currentWorkspaceIndex; + accounts[existingIndex] = { + ...existing, + accountId: nextAccountId, + accountIdSource: nextAccountIdSource, + accountLabel: nextAccountLabel, + email: nextEmail, + refreshToken: result.refresh, + accessToken: result.access, + expiresAt: result.expires, + lastUsed: now, + workspaces: mergedWorkspaces, + currentWorkspaceIndex: nextCurrentWorkspaceIndex, + }; + } + + if (accounts.length === 0) return; + + const activeIndex = replaceAll + ? 0 + : typeof stored?.activeIndex === "number" && + Number.isFinite(stored.activeIndex) + ? stored.activeIndex + : 0; + + const clampedActiveIndex = Math.max( + 0, + Math.min(activeIndex, accounts.length - 1), + ); + const activeIndexByFamily: Partial> = {}; + for (const family of params.modelFamilies) { + const storedFamilyIndex = stored?.activeIndexByFamily?.[family]; + const rawFamilyIndex = replaceAll + ? 0 + : typeof storedFamilyIndex === "number" && + Number.isFinite(storedFamilyIndex) + ? storedFamilyIndex + : clampedActiveIndex; + activeIndexByFamily[family] = Math.max( + 0, + Math.min(Math.floor(rawFamilyIndex), accounts.length - 1), + ); + } + + await persist({ + version: 3, + accounts: accounts as AccountMetadataV3[], + activeIndex: clampedActiveIndex, + activeIndexByFamily, + }); + }); +} diff --git a/lib/runtime/account-selection.ts b/lib/runtime/account-selection.ts new file mode 100644 index 00000000..5d510d63 --- /dev/null +++ b/lib/runtime/account-selection.ts @@ -0,0 +1,85 @@ +import type { Workspace } from "../accounts.js"; +import type { AccountIdSource } from "../types.js"; + +export type TokenSuccessWithAccount< + T extends { access: string; idToken?: string }, +> = T & { + accountIdOverride?: string; + accountIdSource?: AccountIdSource; + accountLabel?: string; + workspaces?: Workspace[]; +}; + +type AccountCandidate = { + accountId: string; + label: string; + isDefault?: boolean; + source: AccountIdSource; +}; + +export function resolveAccountSelection< + T extends { access: string; idToken?: string }, +>( + tokens: T, + deps: { + envAccountId?: string; + logInfo: (message: string) => void; + getAccountIdCandidates: ( + accessToken: string, + idToken?: string, + ) => AccountCandidate[]; + selectBestAccountCandidate: ( + candidates: AccountCandidate[], + ) => AccountCandidate | null | undefined; + }, +): TokenSuccessWithAccount { + const override = (deps.envAccountId ?? "").trim(); + if (override) { + const suffix = override.length > 6 ? override.slice(-6) : override; + deps.logInfo( + `Using account override from CODEX_AUTH_ACCOUNT_ID (id:${suffix}).`, + ); + return { + ...tokens, + accountIdOverride: override, + accountIdSource: "manual", + accountLabel: `Override [id:${suffix}]`, + }; + } + + const candidates = deps.getAccountIdCandidates(tokens.access, tokens.idToken); + if (candidates.length === 0) { + return tokens; + } + + const workspaces: Workspace[] = candidates.map((candidate) => ({ + id: candidate.accountId, + name: candidate.label, + enabled: true, + isDefault: candidate.isDefault, + })); + + if (candidates.length === 1) { + const [candidate] = candidates; + if (candidate) { + return { + ...tokens, + accountIdOverride: candidate.accountId, + accountIdSource: candidate.source, + accountLabel: candidate.label, + workspaces, + }; + } + } + + const choice = deps.selectBestAccountCandidate(candidates); + if (!choice) return tokens; + + return { + ...tokens, + accountIdOverride: choice.accountId, + accountIdSource: choice.source ?? "token", + accountLabel: choice.label, + workspaces, + }; +} diff --git a/lib/runtime/account-status.ts b/lib/runtime/account-status.ts new file mode 100644 index 00000000..267cf663 --- /dev/null +++ b/lib/runtime/account-status.ts @@ -0,0 +1,52 @@ +import type { ModelFamily } from "../prompts/codex.js"; + +export function resolveActiveIndex( + storage: { + activeIndex: number; + activeIndexByFamily?: Partial>; + accounts: unknown[]; + }, + family: ModelFamily = "codex", +): number { + const total = storage.accounts.length; + if (total === 0) return 0; + const rawCandidate = + storage.activeIndexByFamily?.[family] ?? storage.activeIndex; + const raw = Number.isFinite(rawCandidate) ? rawCandidate : 0; + return Math.max(0, Math.min(raw, total - 1)); +} + +export function getRateLimitResetTimeForFamily( + account: { rateLimitResetTimes?: Record }, + now: number, + family: ModelFamily, +): number | null { + const times = account.rateLimitResetTimes; + if (!times) return null; + + let minReset: number | null = null; + const prefix = `${family}:`; + for (const [key, value] of Object.entries(times)) { + if (typeof value !== "number") continue; + if (value <= now) continue; + if (key !== family && !key.startsWith(prefix)) continue; + if (minReset === null || value < minReset) { + minReset = value; + } + } + + return minReset; +} + +export function formatRateLimitEntry( + account: { rateLimitResetTimes?: Record }, + now: number, + formatWaitTime: (ms: number) => string, + family: ModelFamily = "codex", +): string | null { + const resetAt = getRateLimitResetTimeForFamily(account, now, family); + if (typeof resetAt !== "number") return null; + const remaining = resetAt - now; + if (remaining <= 0) return null; + return `resets in ${formatWaitTime(remaining)}`; +} diff --git a/lib/runtime/browser-oauth-flow.ts b/lib/runtime/browser-oauth-flow.ts new file mode 100644 index 00000000..911ef3b4 --- /dev/null +++ b/lib/runtime/browser-oauth-flow.ts @@ -0,0 +1,69 @@ +import type { TokenResult } from "../types.js"; + +export async function runBrowserOAuthFlow(params: { + forceNewLogin?: boolean; + createAuthorizationFlow: (options: { + forceNewLogin: boolean; + }) => Promise<{ pkce: { verifier: string }; state: string; url: string }>; + logInfo: (message: string) => void; + redactOAuthUrlForLog: (url: string) => string; + startLocalOAuthServer: (options: { state: string }) => Promise<{ + ready: boolean; + close: () => void; + waitForCode: (state: string) => Promise<{ code: string } | null>; + }>; + logDebug: (message: string) => void; + openBrowserUrl: (url: string) => void; + pluginName: string; + authManualLabel: string; + logWarn: (message: string) => void; + exchangeAuthorizationCode: ( + code: string, + verifier: string, + redirectUri: string, + ) => Promise; + redirectUri: string; +}): Promise { + const { pkce, state, url } = await params.createAuthorizationFlow({ + forceNewLogin: params.forceNewLogin ?? false, + }); + params.logInfo(`OAuth URL: ${params.redactOAuthUrlForLog(url)}`); + + let serverInfo: Awaited< + ReturnType + > | null = null; + try { + serverInfo = await params.startLocalOAuthServer({ state }); + } catch (err) { + params.logDebug( + `[${params.pluginName}] Failed to start OAuth server: ${(err as Error)?.message ?? String(err)}`, + ); + serverInfo = null; + } + params.openBrowserUrl(url); + + if (!serverInfo || !serverInfo.ready) { + serverInfo?.close(); + params.logWarn( + `\n[${params.pluginName}] OAuth callback server failed to start. Please retry with "${params.authManualLabel}".\n`, + ); + return { type: "failed" as const }; + } + + const result = await serverInfo.waitForCode(state); + serverInfo.close(); + + if (!result) { + return { + type: "failed" as const, + reason: "unknown" as const, + message: "OAuth callback timeout or cancelled", + }; + } + + return params.exchangeAuthorizationCode( + result.code, + pkce.verifier, + params.redirectUri, + ); +} diff --git a/lib/runtime/manual-oauth-flow.ts b/lib/runtime/manual-oauth-flow.ts new file mode 100644 index 00000000..ff16988c --- /dev/null +++ b/lib/runtime/manual-oauth-flow.ts @@ -0,0 +1,77 @@ +import type { TokenResult } from "../types.js"; + +type TokenSuccess = Extract; + +export function buildManualOAuthFlow(params: { + pkce: { verifier: string }; + url: string; + expectedState: string; + redirectUri: string; + parseAuthorizationInput: (input: string) => { + code?: string; + state?: string; + }; + exchangeAuthorizationCode: ( + code: string, + verifier: string, + redirectUri: string, + ) => Promise; + resolveTokenSuccess: (tokens: TokenSuccess) => TResolved; + onSuccess?: (tokens: TResolved) => Promise; + instructions: string; +}): { + url: string; + method: "code"; + instructions: string; + validate: (input: string) => string | undefined; + callback: (input: string) => Promise; +} { + return { + url: params.url, + method: "code", + instructions: params.instructions, + validate: (input: string): string | undefined => { + const parsed = params.parseAuthorizationInput(input); + if (!parsed.code) { + return `No authorization code found. Paste the full callback URL (e.g., ${params.redirectUri}?code=...)`; + } + if (!parsed.state) { + return "Missing OAuth state. Paste the full callback URL including both code and state parameters."; + } + if (parsed.state !== params.expectedState) { + return "OAuth state mismatch. Restart login and paste the callback URL generated for this login attempt."; + } + return undefined; + }, + callback: async (input: string) => { + const parsed = params.parseAuthorizationInput(input); + if (!parsed.code || !parsed.state) { + return { + type: "failed" as const, + reason: "invalid_response" as const, + message: "Missing authorization code or OAuth state", + }; + } + if (parsed.state !== params.expectedState) { + return { + type: "failed" as const, + reason: "invalid_response" as const, + message: "OAuth state mismatch. Restart login and try again.", + }; + } + const tokens = await params.exchangeAuthorizationCode( + parsed.code, + params.pkce.verifier, + params.redirectUri, + ); + if (tokens?.type === "success") { + const resolved = params.resolveTokenSuccess(tokens); + if (params.onSuccess) { + await params.onSuccess(resolved); + } + return resolved; + } + return tokens?.type === "failed" ? tokens : { type: "failed" as const }; + }, + }; +} diff --git a/lib/runtime/runtime-services.ts b/lib/runtime/runtime-services.ts new file mode 100644 index 00000000..b9b6ef1a --- /dev/null +++ b/lib/runtime/runtime-services.ts @@ -0,0 +1,161 @@ +import type { OAuthAuthDetails } from "../types.js"; + +type LiveAccountSyncLike = { + stop: () => void; + syncToPath: (path: string) => Promise; +}; + +type RefreshGuardianLike = { + stop: () => void; + start: () => void; +}; + +type SessionAffinityStoreLike = unknown; + +export async function ensureLiveAccountSyncState< + TSync extends LiveAccountSyncLike, +>(params: { + enabled: boolean; + targetPath: string; + currentSync: TSync | null; + currentPath: string | null; + authFallback?: OAuthAuthDetails; + createSync: (authFallback?: OAuthAuthDetails) => TSync; + registerCleanup: (cleanup: () => void) => void; + logWarn: (message: string) => void; + pluginName: string; +}): Promise<{ + liveAccountSync: TSync | null; + liveAccountSyncPath: string | null; +}> { + let liveAccountSync = params.currentSync; + let liveAccountSyncPath = params.currentPath; + + if (!params.enabled) { + if (liveAccountSync) { + liveAccountSync.stop(); + liveAccountSync = null; + liveAccountSyncPath = null; + } + return { liveAccountSync, liveAccountSyncPath }; + } + + if (!liveAccountSync) { + liveAccountSync = params.createSync(params.authFallback); + params.registerCleanup(() => { + liveAccountSync?.stop(); + }); + } + + if (liveAccountSyncPath !== params.targetPath) { + let switched = false; + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + await liveAccountSync.syncToPath(params.targetPath); + liveAccountSyncPath = params.targetPath; + switched = true; + break; + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code !== "EBUSY" && code !== "EPERM") { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); + } + } + if (!switched) { + params.logWarn( + `[${params.pluginName}] Live account sync path switch failed due to transient filesystem locks; keeping previous watcher.`, + ); + } + } + + return { liveAccountSync, liveAccountSyncPath }; +} + +export function ensureRefreshGuardianState< + TGuardian extends RefreshGuardianLike, +>(params: { + enabled: boolean; + intervalMs: number; + bufferMs: number; + currentGuardian: TGuardian | null; + currentConfigKey: string | null; + createGuardian: (options: { + intervalMs: number; + bufferMs: number; + }) => TGuardian; + registerCleanup: (cleanup: () => void) => void; +}): { + refreshGuardian: TGuardian | null; + refreshGuardianConfigKey: string | null; +} { + let refreshGuardian = params.currentGuardian; + let refreshGuardianConfigKey = params.currentConfigKey; + + if (!params.enabled) { + if (refreshGuardian) { + refreshGuardian.stop(); + refreshGuardian = null; + refreshGuardianConfigKey = null; + } + return { refreshGuardian, refreshGuardianConfigKey }; + } + + const configKey = `${params.intervalMs}:${params.bufferMs}`; + if (refreshGuardian && refreshGuardianConfigKey === configKey) { + return { refreshGuardian, refreshGuardianConfigKey }; + } + + if (refreshGuardian) { + refreshGuardian.stop(); + } + refreshGuardian = params.createGuardian({ + intervalMs: params.intervalMs, + bufferMs: params.bufferMs, + }); + refreshGuardianConfigKey = configKey; + refreshGuardian.start(); + params.registerCleanup(() => { + refreshGuardian?.stop(); + }); + + return { refreshGuardian, refreshGuardianConfigKey }; +} + +export function ensureSessionAffinityState< + TStore extends SessionAffinityStoreLike, +>(params: { + enabled: boolean; + ttlMs: number; + maxEntries: number; + currentStore: TStore | null; + currentConfigKey: string | null; + createStore: (options: { ttlMs: number; maxEntries: number }) => TStore; +}): { + sessionAffinityStore: TStore | null; + sessionAffinityConfigKey: string | null; +} { + if (!params.enabled) { + return { + sessionAffinityStore: null, + sessionAffinityConfigKey: null, + }; + } + + const configKey = `${params.ttlMs}:${params.maxEntries}`; + if (params.currentStore && params.currentConfigKey === configKey) { + return { + sessionAffinityStore: params.currentStore, + sessionAffinityConfigKey: params.currentConfigKey, + }; + } + + return { + sessionAffinityStore: params.createStore({ + ttlMs: params.ttlMs, + maxEntries: params.maxEntries, + }), + sessionAffinityConfigKey: configKey, + }; +} diff --git a/lib/runtime/storage-scope.ts b/lib/runtime/storage-scope.ts new file mode 100644 index 00000000..59bcb93e --- /dev/null +++ b/lib/runtime/storage-scope.ts @@ -0,0 +1,34 @@ +export function applyAccountStorageScopeFromConfig( + pluginConfig: ReturnType, + deps: { + getPerProjectAccounts: ( + config: ReturnType, + ) => boolean; + getStorageBackupEnabled: ( + config: ReturnType, + ) => boolean; + setStorageBackupEnabled: (enabled: boolean) => void; + isCodexCliSyncEnabled: () => boolean; + getWarningShown: () => boolean; + setWarningShown: (shown: boolean) => void; + logWarn: (message: string) => void; + pluginName: string; + setStoragePath: (path: string | null) => void; + cwd: () => string; + }, +): void { + const perProjectAccounts = deps.getPerProjectAccounts(pluginConfig); + deps.setStorageBackupEnabled(deps.getStorageBackupEnabled(pluginConfig)); + if (deps.isCodexCliSyncEnabled()) { + if (perProjectAccounts && !deps.getWarningShown()) { + deps.setWarningShown(true); + deps.logWarn( + `[${deps.pluginName}] CODEX_AUTH_PER_PROJECT_ACCOUNTS is ignored while Codex CLI sync is enabled. Using global account storage.`, + ); + } + deps.setStoragePath(null); + return; + } + + deps.setStoragePath(perProjectAccounts ? deps.cwd() : null); +} diff --git a/lib/runtime/ui-runtime.ts b/lib/runtime/ui-runtime.ts new file mode 100644 index 00000000..7c921ff7 --- /dev/null +++ b/lib/runtime/ui-runtime.ts @@ -0,0 +1,35 @@ +import { + getCodexTuiColorProfile, + getCodexTuiGlyphMode, + getCodexTuiV2, +} from "../config.js"; +import type { UiRuntimeOptions } from "../ui/runtime.js"; + +export function applyUiRuntimeFromConfig( + pluginConfig: ReturnType, + setUiRuntimeOptions: (options: { + v2Enabled: boolean; + colorProfile: ReturnType; + glyphMode: ReturnType; + }) => UiRuntimeOptions, +): UiRuntimeOptions { + return setUiRuntimeOptions({ + v2Enabled: getCodexTuiV2(pluginConfig), + colorProfile: getCodexTuiColorProfile(pluginConfig), + glyphMode: getCodexTuiGlyphMode(pluginConfig), + }); +} + +export function getStatusMarker( + ui: UiRuntimeOptions, + status: "ok" | "warning" | "error", +): string { + if (!ui.v2Enabled) { + if (status === "ok") return "✓"; + if (status === "warning") return "!"; + return "✗"; + } + if (status === "ok") return ui.theme.glyphs.check; + if (status === "warning") return "!"; + return ui.theme.glyphs.cross; +} diff --git a/lib/storage.ts b/lib/storage.ts index 0509925d..f7986b9d 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1,7 +1,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { createHash } from "node:crypto"; import { existsSync, promises as fs } from "node:fs"; -import { basename, dirname, isAbsolute, join, relative } from "node:path"; +import { basename, dirname, join } from "node:path"; import { ACCOUNT_LIMITS } from "./constants.js"; import { createLogger } from "./logger.js"; import { @@ -10,7 +10,32 @@ import { resolveNamedBackupPath, } from "./named-backup-export.js"; import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; -import { AnyAccountStorageSchema, getValidationErrors } from "./schemas.js"; +import { clearAccountStorageArtifacts } from "./storage/account-clear.js"; +import { cloneAccountStorageForPersistence } from "./storage/account-persistence.js"; +import { buildBackupMetadata } from "./storage/backup-metadata-builder.js"; +import { + ACCOUNTS_BACKUP_SUFFIX, + ACCOUNTS_WAL_SUFFIX, + getAccountsBackupPath, + getAccountsBackupRecoveryCandidates, + getAccountsWalPath, + getIntentionalResetMarkerPath, + RESET_MARKER_SUFFIX, +} from "./storage/backup-paths.js"; +import { restoreAccountsFromBackupPath } from "./storage/backup-restore.js"; +import { looksLikeSyntheticFixtureStorage } from "./storage/fixture-guards.js"; +import { normalizeFlaggedStorage } from "./storage/flagged-storage.js"; +import { + clearFlaggedAccountsOnDisk, + loadFlaggedAccountsState, + saveFlaggedAccountsUnlockedToDisk, +} from "./storage/flagged-storage-io.js"; +import { + exportAccountsToFile, + mergeImportedAccounts, + readImportFile, +} from "./storage/import-export.js"; +import { buildMetadataSection } from "./storage/metadata-section.js"; import { type AccountMetadataV1, type AccountMetadataV3, @@ -20,6 +45,10 @@ import { migrateV1ToV3, type RateLimitStateV3, } from "./storage/migrations.js"; +import { + collectNamedBackups, + type NamedBackupSummary, +} from "./storage/named-backups.js"; import { findProjectRoot, getConfigDir, @@ -28,6 +57,21 @@ import { resolvePath, resolveProjectStorageIdentityRoot, } from "./storage/paths.js"; +import { + loadNormalizedStorageFromPath, + mergeStorageForMigration, +} from "./storage/project-migration.js"; +import { buildRestoreAssessment } from "./storage/restore-assessment.js"; +import { + loadAccountsFromPath, + parseAndNormalizeStorage, +} from "./storage/storage-parser.js"; +import { + getTransactionSnapshotState, + withAccountAndFlaggedStorageTransaction as runWithAccountAndFlaggedStorageTransaction, + withAccountStorageTransaction as runWithAccountStorageTransaction, + withStorageLock, +} from "./storage/transactions.js"; export type { CooldownReason, @@ -36,18 +80,16 @@ export type { AccountStorageV1, AccountMetadataV3, AccountStorageV3, + NamedBackupSummary, }; const log = createLogger("storage"); const ACCOUNTS_FILE_NAME = "openai-codex-accounts.json"; const FLAGGED_ACCOUNTS_FILE_NAME = "openai-codex-flagged-accounts.json"; const LEGACY_FLAGGED_ACCOUNTS_FILE_NAME = "openai-codex-blocked-accounts.json"; -const ACCOUNTS_BACKUP_SUFFIX = ".bak"; -const ACCOUNTS_WAL_SUFFIX = ".wal"; const ACCOUNTS_BACKUP_HISTORY_DEPTH = 3; const BACKUP_COPY_MAX_ATTEMPTS = 5; const BACKUP_COPY_BASE_DELAY_MS = 10; -const RESET_MARKER_SUFFIX = ".reset-intent"; let storageBackupEnabled = true; let lastAccountsSaveTimestamp = 0; @@ -115,74 +157,6 @@ export type RestoreAssessment = { backupMetadata: BackupMetadata; }; -export interface NamedBackupSummary { - path: string; - fileName: string; - accountCount: number; - mtimeMs: number; -} - -async function collectNamedBackups(storagePath: string): Promise { - const backupRoot = getNamedBackupRoot(storagePath); - let entries: Array<{ isFile(): boolean; name: string }>; - try { - entries = await fs.readdir(backupRoot, { - withFileTypes: true, - encoding: "utf8", - }); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") return []; - throw error; - } - - const candidates: NamedBackupSummary[] = []; - for (const entry of entries) { - if (!entry.isFile()) continue; - if (!entry.name.toLowerCase().endsWith(".json")) continue; - const candidatePath = join(backupRoot, entry.name); - try { - const statsBefore = await fs.stat(candidatePath); - const { normalized } = await loadAccountsFromPath(candidatePath); - if (!normalized || normalized.accounts.length === 0) continue; - const statsAfter = await fs.stat(candidatePath).catch(() => null); - if (statsAfter && statsAfter.mtimeMs !== statsBefore.mtimeMs) { - log.debug("backup file changed between stat and load, mtime may be stale", { - candidatePath, - fileName: entry.name, - beforeMtimeMs: statsBefore.mtimeMs, - afterMtimeMs: statsAfter.mtimeMs, - }); - } - candidates.push({ - path: candidatePath, - fileName: entry.name, - accountCount: normalized.accounts.length, - mtimeMs: statsBefore.mtimeMs, - }); - } catch (error) { - log.debug("Skipping named backup candidate after loadAccountsFromPath/fs.stat failure", { - candidatePath, - fileName: entry.name, - error: error instanceof Error - ? { - message: error.message, - stack: error.stack, - } - : String(error), - }); - continue; - } - } - - candidates.sort((left, right) => { - const mtimeDelta = right.mtimeMs - left.mtimeMs; - if (mtimeDelta !== 0) return mtimeDelta; - return left.fileName.localeCompare(right.fileName); - }); - return candidates; -} - /** * Custom error class for storage operations with platform-aware hints. */ @@ -233,22 +207,6 @@ export function formatStorageErrorHint(error: unknown, path: string): string { } } -let storageMutex: Promise = Promise.resolve(); -const transactionSnapshotContext = new AsyncLocalStorage<{ - snapshot: AccountStorageV3 | null; - storagePath: string; - active: boolean; -}>(); - -function withStorageLock(fn: () => Promise): Promise { - const previousMutex = storageMutex; - let releaseLock: () => void; - storageMutex = new Promise((resolve) => { - releaseLock = resolve; - }); - return previousMutex.then(fn).finally(() => releaseLock()); -} - type AnyAccountStorage = AccountStorageV1 | AccountStorageV3; type AccountLike = { @@ -259,41 +217,6 @@ type AccountLike = { lastUsed?: number; }; -function looksLikeSyntheticFixtureAccount(account: AccountMetadataV3): boolean { - const email = - typeof account.email === "string" ? account.email.trim().toLowerCase() : ""; - const refreshToken = - typeof account.refreshToken === "string" - ? account.refreshToken.trim().toLowerCase() - : ""; - const accountId = - typeof account.accountId === "string" - ? account.accountId.trim().toLowerCase() - : ""; - if (!/^account\d+@example\.com$/.test(email)) { - return false; - } - const hasSyntheticRefreshToken = - refreshToken.startsWith("fake_refresh") || - /^fake_refresh_token_\d+(_for_testing_only)?$/.test(refreshToken); - if (!hasSyntheticRefreshToken) { - return false; - } - if (accountId.length === 0) { - return true; - } - return /^acc(_|-)?\d+$/.test(accountId); -} - -function looksLikeSyntheticFixtureStorage( - storage: AccountStorageV3 | null, -): boolean { - if (!storage || storage.accounts.length === 0) return false; - return storage.accounts.every((account) => - looksLikeSyntheticFixtureAccount(account), - ); -} - async function ensureGitignore(storagePath: string): Promise { const state = getStoragePathState(); if (!state.currentStoragePath) return; @@ -362,29 +285,13 @@ export function setStorageBackupEnabled(enabled: boolean): void { storageBackupEnabled = enabled; } -function getAccountsBackupPath(path: string): string { - return `${path}${ACCOUNTS_BACKUP_SUFFIX}`; -} - -function getAccountsBackupPathAtIndex(path: string, index: number): string { - if (index <= 0) { - return getAccountsBackupPath(path); - } - return `${path}${ACCOUNTS_BACKUP_SUFFIX}.${index}`; -} - -function getAccountsBackupRecoveryCandidates(path: string): string[] { - const candidates: string[] = []; - for (let i = 0; i < ACCOUNTS_BACKUP_HISTORY_DEPTH; i += 1) { - candidates.push(getAccountsBackupPathAtIndex(path, i)); - } - return candidates; -} - async function getAccountsBackupRecoveryCandidatesWithDiscovery( path: string, ): Promise { - const knownCandidates = getAccountsBackupRecoveryCandidates(path); + const knownCandidates = getAccountsBackupRecoveryCandidates( + path, + ACCOUNTS_BACKUP_HISTORY_DEPTH, + ); const discoveredCandidates = new Set(); const candidatePrefix = `${basename(path)}.`; const knownCandidateSet = new Set(knownCandidates); @@ -420,10 +327,6 @@ async function getAccountsBackupRecoveryCandidatesWithDiscovery( return [...knownCandidates, ...discoveredOrdered]; } -function getAccountsWalPath(path: string): string { - return `${path}${ACCOUNTS_WAL_SUFFIX}`; -} - async function copyFileWithRetry( sourcePath: string, destinationPath: string, @@ -481,7 +384,10 @@ async function renameFileWithRetry( } async function createRotatingAccountsBackup(path: string): Promise { - const candidates = getAccountsBackupRecoveryCandidates(path); + const candidates = getAccountsBackupRecoveryCandidates( + path, + ACCOUNTS_BACKUP_HISTORY_DEPTH, + ); const rotationNonce = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; const stagedWrites: Array<{ targetPath: string; stagedPath: string }> = []; const buildStagedPath = (targetPath: string, label: string): string => @@ -600,10 +506,6 @@ function computeSha256(value: string): string { return createHash("sha256").update(value).digest("hex"); } -function getIntentionalResetMarkerPath(path: string): string { - return `${path}${RESET_MARKER_SUFFIX}`; -} - function createEmptyStorageWithMetadata( restoreEligible: boolean, restoreReason: RestoreReason, @@ -665,7 +567,10 @@ async function describeAccountSnapshot( } try { const { normalized, schemaErrors, storedVersion } = - await loadAccountsFromPath(path); + await loadAccountsFromPath(path, { + normalizeAccountStorage, + isRecord, + }); return { kind, path, @@ -735,7 +640,11 @@ async function describeAccountsWalSnapshot( }; } const { normalized, storedVersion, schemaErrors } = - parseAndNormalizeStorage(JSON.parse(entry.content) as unknown); + parseAndNormalizeStorage( + JSON.parse(entry.content) as unknown, + normalizeAccountStorage, + isRecord, + ); return { kind: "accounts-wal", path, @@ -764,7 +673,10 @@ async function loadFlaggedAccountsFromPath( ): Promise { const content = await fs.readFile(path, "utf-8"); const data = JSON.parse(content) as unknown; - return normalizeFlaggedStorage(data); + return normalizeFlaggedStorage(data, { + isRecord, + now: () => Date.now(), + }); } async function describeFlaggedSnapshot( @@ -809,28 +721,6 @@ async function describeFlaggedSnapshot( } } -function latestValidSnapshot( - snapshots: BackupSnapshotMetadata[], -): BackupSnapshotMetadata | undefined { - return snapshots - .filter((snapshot) => snapshot.valid) - .sort((left, right) => (right.mtimeMs ?? 0) - (left.mtimeMs ?? 0))[0]; -} - -function buildMetadataSection( - storagePath: string, - snapshots: BackupSnapshotMetadata[], -): BackupMetadataSection { - const latestValid = latestValidSnapshot(snapshots); - return { - storagePath, - latestValidPath: latestValid?.path, - snapshotCount: snapshots.length, - validSnapshotCount: snapshots.filter((snapshot) => snapshot.valid).length, - snapshots, - }; -} - type AccountsJournalEntry = { version: 1; createdAt: number; @@ -915,63 +805,33 @@ export function buildNamedBackupPath(name: string): string { } export async function getNamedBackups(): Promise { - return collectNamedBackups(getStoragePath()); + return collectNamedBackups(getStoragePath(), { + loadAccountsFromPath: (path) => + loadAccountsFromPath(path, { + normalizeAccountStorage, + isRecord, + }), + logDebug: (message, details) => { + log.debug(message, details); + }, + }); } export async function restoreAccountsFromBackup( path: string, options?: { persist?: boolean }, ): Promise { - const backupRoot = getNamedBackupRoot(getStoragePath()); - let resolvedBackupRoot: string; - try { - resolvedBackupRoot = await fs.realpath(backupRoot); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - throw new Error(`Backup root does not exist: ${backupRoot}`); - } - throw error; - } - let resolvedBackupPath: string; - try { - resolvedBackupPath = await fs.realpath(path); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - throw new Error(`Backup file no longer exists: ${path}`); - } - throw error; - } - const relativePath = relative(resolvedBackupRoot, resolvedBackupPath); - const isInsideBackupRoot = - relativePath.length > 0 && - !relativePath.startsWith("..") && - !isAbsolute(relativePath); - if (!isInsideBackupRoot) { - throw new Error(`Backup path must stay inside ${resolvedBackupRoot}: ${path}`); - } - - const { normalized } = await (async () => { - try { - return await loadAccountsFromPath(resolvedBackupPath); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - throw new Error( - `Backup file no longer exists: ${path}`, - ); - } - throw error; - } - })(); - if (!normalized || normalized.accounts.length === 0) { - throw new Error(`Backup does not contain any accounts: ${resolvedBackupPath}`); - } - if (options?.persist !== false) { - await saveAccounts(normalized); - } - return normalized; + return restoreAccountsFromBackupPath(path, { + persist: options?.persist, + backupRoot: getNamedBackupRoot(getStoragePath()), + realpath: fs.realpath, + loadAccountsFromPath: (path) => + loadAccountsFromPath(path, { + normalizeAccountStorage, + isRecord, + }), + saveAccounts, + }); } export async function exportNamedBackup( @@ -1030,6 +890,16 @@ async function migrateLegacyProjectStorageIfNeeded( let targetStorage = await loadNormalizedStorageFromPath( state.currentStoragePath, "current account storage", + { + loadAccountsFromPath: (path) => + loadAccountsFromPath(path, { + normalizeAccountStorage, + isRecord, + }), + logWarn: (message, details) => { + log.warn(message, details); + }, + }, ); let migrated = false; @@ -1037,6 +907,16 @@ async function migrateLegacyProjectStorageIfNeeded( const legacyStorage = await loadNormalizedStorageFromPath( legacyPath, "legacy account storage", + { + loadAccountsFromPath: (path) => + loadAccountsFromPath(path, { + normalizeAccountStorage, + isRecord, + }), + logWarn: (message, details) => { + log.warn(message, details); + }, + }, ); if (!legacyStorage) { continue; @@ -1045,6 +925,7 @@ async function migrateLegacyProjectStorageIfNeeded( const mergedStorage = mergeStorageForMigration( targetStorage, legacyStorage, + normalizeAccountStorage, ); const fallbackStorage = targetStorage ?? legacyStorage; @@ -1096,51 +977,6 @@ async function migrateLegacyProjectStorageIfNeeded( return null; } -async function loadNormalizedStorageFromPath( - path: string, - label: string, -): Promise { - try { - const { normalized, schemaErrors } = await loadAccountsFromPath(path); - if (schemaErrors.length > 0) { - log.warn(`${label} schema validation warnings`, { - path, - errors: schemaErrors.slice(0, 5), - }); - } - return normalized; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - log.warn(`Failed to load ${label}`, { - path, - error: String(error), - }); - } - return null; - } -} - -function mergeStorageForMigration( - current: AccountStorageV3 | null, - incoming: AccountStorageV3, -): AccountStorageV3 { - if (!current) { - return incoming; - } - - const merged = normalizeAccountStorage({ - version: 3, - activeIndex: current.activeIndex, - activeIndexByFamily: current.activeIndexByFamily, - accounts: [...current.accounts, ...incoming.accounts], - }); - if (!merged) { - return current; - } - return merged; -} - function selectNewestAccount( current: T | undefined, candidate: T, @@ -1330,7 +1166,10 @@ function findCompatibleRefreshTokenMatchIndex( matchingAccount = account; continue; } - const newest: T = selectNewestAccount(matchingAccount ?? undefined, account); + const newest: T = selectNewestAccount( + matchingAccount ?? undefined, + account, + ); if (newest === account) { matchingIndex = i; matchingAccount = account; @@ -1601,121 +1440,28 @@ export async function loadAccounts(): Promise { export async function getBackupMetadata(): Promise { const storagePath = getStoragePath(); - const walPath = getAccountsWalPath(storagePath); - const accountCandidates = - await getAccountsBackupRecoveryCandidatesWithDiscovery(storagePath); - const accountSnapshots: BackupSnapshotMetadata[] = [ - await describeAccountSnapshot(storagePath, "accounts-primary"), - await describeAccountsWalSnapshot(walPath), - ]; - for (const [index, candidate] of accountCandidates.entries()) { - const kind: BackupSnapshotKind = - candidate === `${storagePath}.bak` - ? "accounts-backup" - : candidate.startsWith(`${storagePath}.bak.`) - ? "accounts-backup-history" - : "accounts-discovered-backup"; - accountSnapshots.push( - await describeAccountSnapshot(candidate, kind, index), - ); - } - const flaggedPath = getFlaggedAccountsPath(); - const flaggedCandidates = - await getAccountsBackupRecoveryCandidatesWithDiscovery(flaggedPath); - const flaggedSnapshots: BackupSnapshotMetadata[] = [ - await describeFlaggedSnapshot(flaggedPath, "flagged-primary"), - ]; - for (const [index, candidate] of flaggedCandidates.entries()) { - const kind: BackupSnapshotKind = - candidate === `${flaggedPath}.bak` - ? "flagged-backup" - : candidate.startsWith(`${flaggedPath}.bak.`) - ? "flagged-backup-history" - : "flagged-discovered-backup"; - flaggedSnapshots.push( - await describeFlaggedSnapshot(candidate, kind, index), - ); - } - - return { - accounts: buildMetadataSection(storagePath, accountSnapshots), - flaggedAccounts: buildMetadataSection(flaggedPath, flaggedSnapshots), - }; + return buildBackupMetadata({ + storagePath, + flaggedPath, + walPath: getAccountsWalPath(storagePath), + getAccountsBackupRecoveryCandidatesWithDiscovery, + describeAccountSnapshot, + describeAccountsWalSnapshot, + describeFlaggedSnapshot, + buildMetadataSection, + }); } export async function getRestoreAssessment(): Promise { const storagePath = getStoragePath(); const resetMarkerPath = getIntentionalResetMarkerPath(storagePath); const backupMetadata = await getBackupMetadata(); - if (existsSync(resetMarkerPath)) { - return { - storagePath, - restoreEligible: false, - restoreReason: "intentional-reset", - backupMetadata, - }; - } - const primarySnapshot = backupMetadata.accounts.snapshots.find( - (snapshot) => snapshot.kind === "accounts-primary", - ); - if (!primarySnapshot?.exists) { - return { - storagePath, - restoreEligible: true, - restoreReason: "missing-storage", - latestSnapshot: backupMetadata.accounts.latestValidPath - ? backupMetadata.accounts.snapshots.find( - (snapshot) => - snapshot.path === backupMetadata.accounts.latestValidPath, - ) - : undefined, - backupMetadata, - }; - } - if (primarySnapshot.valid && primarySnapshot.accountCount === 0) { - return { - storagePath, - restoreEligible: true, - restoreReason: "empty-storage", - latestSnapshot: primarySnapshot, - backupMetadata, - }; - } - return { + return buildRestoreAssessment({ storagePath, - restoreEligible: false, - latestSnapshot: backupMetadata.accounts.latestValidPath - ? backupMetadata.accounts.snapshots.find( - (snapshot) => - snapshot.path === backupMetadata.accounts.latestValidPath, - ) - : undefined, backupMetadata, - }; -} - -function parseAndNormalizeStorage(data: unknown): { - normalized: AccountStorageV3 | null; - storedVersion: unknown; - schemaErrors: string[]; -} { - const schemaErrors = getValidationErrors(AnyAccountStorageSchema, data); - const normalized = normalizeAccountStorage(data); - const storedVersion = isRecord(data) - ? (data as { version?: unknown }).version - : undefined; - return { normalized, storedVersion, schemaErrors }; -} - -async function loadAccountsFromPath(path: string): Promise<{ - normalized: AccountStorageV3 | null; - storedVersion: unknown; - schemaErrors: string[]; -}> { - const content = await fs.readFile(path, "utf-8"); - const data = JSON.parse(content) as unknown; - return parseAndNormalizeStorage(data); + hasResetMarker: existsSync(resetMarkerPath), + }); } async function loadAccountsFromJournal( @@ -1743,7 +1489,11 @@ async function loadAccountsFromJournal( return null; } const data = JSON.parse(entry.content) as unknown; - const { normalized } = parseAndNormalizeStorage(data); + const { normalized } = parseAndNormalizeStorage( + data, + normalizeAccountStorage, + isRecord, + ); if (!normalized) return null; log.warn("Recovered account storage from WAL journal", { path, walPath }); return normalized; @@ -1771,7 +1521,10 @@ async function loadAccountsInternal( try { const { normalized, storedVersion, schemaErrors } = - await loadAccountsFromPath(path); + await loadAccountsFromPath(path, { + normalizeAccountStorage, + isRecord, + }); if (schemaErrors.length > 0) { log.warn("Account storage schema validation warnings", { errors: schemaErrors.slice(0, 5), @@ -1808,7 +1561,10 @@ async function loadAccountsInternal( for (const backupPath of backupCandidates) { if (backupPath === path) continue; try { - const backup = await loadAccountsFromPath(backupPath); + const backup = await loadAccountsFromPath(backupPath, { + normalizeAccountStorage, + isRecord, + }); if (!backup.normalized) continue; if (looksLikeSyntheticFixtureStorage(backup.normalized)) continue; if (backup.normalized.accounts.length <= 0) continue; @@ -1880,7 +1636,10 @@ async function loadAccountsInternal( await getAccountsBackupRecoveryCandidatesWithDiscovery(path); for (const backupPath of backupCandidates) { try { - const backup = await loadAccountsFromPath(backupPath); + const backup = await loadAccountsFromPath(backupPath, { + normalizeAccountStorage, + isRecord, + }); if (backup.schemaErrors.length > 0) { log.warn("Backup account storage schema validation warnings", { path: backupPath, @@ -1942,6 +1701,16 @@ async function saveAccountsUnlocked(storage: AccountStorageV3): Promise { const existing = await loadNormalizedStorageFromPath( path, "existing account storage", + { + loadAccountsFromPath: (path) => + loadAccountsFromPath(path, { + normalizeAccountStorage, + isRecord, + }), + logWarn: (message, details) => { + log.warn(message, details); + }, + }, ); if ( existing && @@ -2054,42 +1823,16 @@ async function saveAccountsUnlocked(storage: AccountStorageV3): Promise { } } -function cloneAccountStorageForPersistence( - storage: AccountStorageV3 | null | undefined, -): AccountStorageV3 { - return { - version: 3, - accounts: structuredClone(storage?.accounts ?? []), - activeIndex: - typeof storage?.activeIndex === "number" && - Number.isFinite(storage.activeIndex) - ? storage.activeIndex - : 0, - activeIndexByFamily: structuredClone(storage?.activeIndexByFamily ?? {}), - }; -} - export async function withAccountStorageTransaction( handler: ( current: AccountStorageV3 | null, persist: (storage: AccountStorageV3) => Promise, ) => Promise, ): Promise { - return withStorageLock(async () => { - const storagePath = getStoragePath(); - const state = { - snapshot: await loadAccountsInternal(saveAccountsUnlocked), - storagePath, - active: true, - }; - const current = state.snapshot; - const persist = async (storage: AccountStorageV3): Promise => { - await saveAccountsUnlocked(storage); - state.snapshot = storage; - }; - return transactionSnapshotContext.run(state, () => - handler(current, persist), - ); + return runWithAccountStorageTransaction(handler, { + getStoragePath, + loadCurrent: () => loadAccountsInternal(saveAccountsUnlocked), + saveAccounts: saveAccountsUnlocked, }); } @@ -2102,48 +1845,21 @@ export async function withAccountAndFlaggedStorageTransaction( ) => Promise, ) => Promise, ): Promise { - return withStorageLock(async () => { - const storagePath = getStoragePath(); - const state = { - snapshot: await loadAccountsInternal(saveAccountsUnlocked), - storagePath, - active: true, - }; - const current = state.snapshot; - const persist = async ( - accountStorage: AccountStorageV3, - flaggedStorage: FlaggedAccountStorageV1, - ): Promise => { - const previousAccounts = cloneAccountStorageForPersistence(state.snapshot); - const nextAccounts = cloneAccountStorageForPersistence(accountStorage); - await saveAccountsUnlocked(nextAccounts); - try { - await saveFlaggedAccountsUnlocked(flaggedStorage); - state.snapshot = nextAccounts; - } catch (error) { - try { - await saveAccountsUnlocked(previousAccounts); - state.snapshot = previousAccounts; - } catch (rollbackError) { - const combinedError = new AggregateError( - [error, rollbackError], - "Flagged save failed and account storage rollback also failed", - ); - log.error( - "Failed to rollback account storage after flagged save failure", - { - error: String(error), - rollbackError: String(rollbackError), - }, - ); - throw combinedError; - } - throw error; - } - }; - return transactionSnapshotContext.run(state, () => - handler(current, persist), - ); + return runWithAccountAndFlaggedStorageTransaction(handler, { + getStoragePath, + loadCurrent: () => loadAccountsInternal(saveAccountsUnlocked), + saveAccounts: saveAccountsUnlocked, + saveFlaggedAccounts: saveFlaggedAccountsUnlocked, + cloneAccountStorageForPersistence, + logRollbackError: (error, rollbackError) => { + log.error( + "Failed to rollback account storage after flagged save failure", + { + error: String(error), + rollbackError: String(rollbackError), + }, + ); + }, }); } @@ -2167,256 +1883,60 @@ export async function saveAccounts(storage: AccountStorageV3): Promise { export async function clearAccounts(): Promise { return withStorageLock(async () => { const path = getStoragePath(); - const resetMarkerPath = getIntentionalResetMarkerPath(path); - const walPath = getAccountsWalPath(path); - const backupPaths = - await getAccountsBackupRecoveryCandidatesWithDiscovery(path); - await fs.writeFile( - resetMarkerPath, - JSON.stringify({ version: 1, createdAt: Date.now() }), - { encoding: "utf-8", mode: 0o600 }, - ); - const clearPath = async (targetPath: string): Promise => { - try { - await fs.unlink(targetPath); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - log.error("Failed to clear account storage artifact", { - path: targetPath, - error: String(error), - }); - } - } - }; - - try { - await Promise.all([ - clearPath(path), - clearPath(walPath), - ...backupPaths.map(clearPath), - ]); - } catch { - // Individual path cleanup is already best-effort with per-artifact logging. - } + await clearAccountStorageArtifacts({ + path, + resetMarkerPath: getIntentionalResetMarkerPath(path), + walPath: getAccountsWalPath(path), + backupPaths: await getAccountsBackupRecoveryCandidatesWithDiscovery(path), + logError: (message, details) => { + log.error(message, details); + }, + }); }); } -function normalizeFlaggedStorage(data: unknown): FlaggedAccountStorageV1 { - if (!isRecord(data) || data.version !== 1 || !Array.isArray(data.accounts)) { - return { version: 1, accounts: [] }; - } - - const byRefreshToken = new Map(); - for (const rawAccount of data.accounts) { - if (!isRecord(rawAccount)) continue; - const refreshToken = - typeof rawAccount.refreshToken === "string" - ? rawAccount.refreshToken.trim() - : ""; - if (!refreshToken) continue; - - const flaggedAt = - typeof rawAccount.flaggedAt === "number" - ? rawAccount.flaggedAt - : Date.now(); - const isAccountIdSource = ( - value: unknown, - ): value is AccountMetadataV3["accountIdSource"] => - value === "token" || - value === "id_token" || - value === "org" || - value === "manual"; - const isSwitchReason = ( - value: unknown, - ): value is AccountMetadataV3["lastSwitchReason"] => - value === "rate-limit" || - value === "initial" || - value === "rotation" || - value === "best" || - value === "restore"; - const isCooldownReason = ( - value: unknown, - ): value is AccountMetadataV3["cooldownReason"] => - value === "auth-failure" || - value === "network-error" || - value === "rate-limit"; - - let rateLimitResetTimes: - | AccountMetadataV3["rateLimitResetTimes"] - | undefined; - if (isRecord(rawAccount.rateLimitResetTimes)) { - const normalizedRateLimits: Record = {}; - for (const [key, value] of Object.entries( - rawAccount.rateLimitResetTimes, - )) { - if (typeof value === "number") { - normalizedRateLimits[key] = value; - } - } - if (Object.keys(normalizedRateLimits).length > 0) { - rateLimitResetTimes = normalizedRateLimits; - } - } - - const accountIdSource = isAccountIdSource(rawAccount.accountIdSource) - ? rawAccount.accountIdSource - : undefined; - const lastSwitchReason = isSwitchReason(rawAccount.lastSwitchReason) - ? rawAccount.lastSwitchReason - : undefined; - const cooldownReason = isCooldownReason(rawAccount.cooldownReason) - ? rawAccount.cooldownReason - : undefined; - - const normalized: FlaggedAccountMetadataV1 = { - refreshToken, - addedAt: - typeof rawAccount.addedAt === "number" ? rawAccount.addedAt : flaggedAt, - lastUsed: - typeof rawAccount.lastUsed === "number" - ? rawAccount.lastUsed - : flaggedAt, - accountId: - typeof rawAccount.accountId === "string" - ? rawAccount.accountId - : undefined, - accountIdSource, - accountLabel: - typeof rawAccount.accountLabel === "string" - ? rawAccount.accountLabel - : undefined, - email: - typeof rawAccount.email === "string" ? rawAccount.email : undefined, - enabled: - typeof rawAccount.enabled === "boolean" - ? rawAccount.enabled - : undefined, - lastSwitchReason, - rateLimitResetTimes, - coolingDownUntil: - typeof rawAccount.coolingDownUntil === "number" - ? rawAccount.coolingDownUntil - : undefined, - cooldownReason, - flaggedAt, - flaggedReason: - typeof rawAccount.flaggedReason === "string" - ? rawAccount.flaggedReason - : undefined, - lastError: - typeof rawAccount.lastError === "string" - ? rawAccount.lastError - : undefined, - }; - byRefreshToken.set(refreshToken, normalized); - } - - return { - version: 1, - accounts: Array.from(byRefreshToken.values()), - }; -} - export async function loadFlaggedAccounts(): Promise { const path = getFlaggedAccountsPath(); - const resetMarkerPath = getIntentionalResetMarkerPath(path); - const empty: FlaggedAccountStorageV1 = { version: 1, accounts: [] }; - - try { - const content = await fs.readFile(path, "utf-8"); - const data = JSON.parse(content) as unknown; - const loaded = normalizeFlaggedStorage(data); - if (existsSync(resetMarkerPath)) { - return empty; - } - return loaded; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - log.error("Failed to load flagged account storage", { - path, - error: String(error), - }); - return empty; - } - } - - const legacyPath = getLegacyFlaggedAccountsPath(); - if (!existsSync(legacyPath)) { - return empty; - } - - try { - const legacyContent = await fs.readFile(legacyPath, "utf-8"); - const legacyData = JSON.parse(legacyContent) as unknown; - const migrated = normalizeFlaggedStorage(legacyData); - if (migrated.accounts.length > 0) { - await saveFlaggedAccounts(migrated); - } - try { - await fs.unlink(legacyPath); - } catch { - // Best effort cleanup. - } - log.info("Migrated legacy flagged account storage", { - from: legacyPath, - to: path, - accounts: migrated.accounts.length, - }); - return migrated; - } catch (error) { - log.error("Failed to migrate legacy flagged account storage", { - from: legacyPath, - to: path, - error: String(error), - }); - return empty; - } + return loadFlaggedAccountsState({ + path, + legacyPath: getLegacyFlaggedAccountsPath(), + resetMarkerPath: getIntentionalResetMarkerPath(path), + normalizeFlaggedStorage: (data) => + normalizeFlaggedStorage(data, { + isRecord, + now: () => Date.now(), + }), + saveFlaggedAccounts, + logError: (message, details) => { + log.error(message, details); + }, + logInfo: (message, details) => { + log.info(message, details); + }, + }); } async function saveFlaggedAccountsUnlocked( storage: FlaggedAccountStorageV1, ): Promise { const path = getFlaggedAccountsPath(); - const markerPath = getIntentionalResetMarkerPath(path); - const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; - const tempPath = `${path}.${uniqueSuffix}.tmp`; - - try { - await fs.mkdir(dirname(path), { recursive: true }); - if (existsSync(path)) { - try { - await copyFileWithRetry(path, `${path}.bak`, { - allowMissingSource: true, - }); - } catch (backupError) { - log.warn("Failed to create flagged backup snapshot", { - path, - error: String(backupError), - }); - } - } - const content = JSON.stringify(normalizeFlaggedStorage(storage), null, 2); - await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 }); - await renameFileWithRetry(tempPath, path); - try { - await fs.unlink(markerPath); - } catch { - // Best effort cleanup. - } - } catch (error) { - try { - await fs.unlink(tempPath); - } catch { - // Ignore cleanup failures. - } - log.error("Failed to save flagged account storage", { - path, - error: String(error), - }); - throw error; - } + return saveFlaggedAccountsUnlockedToDisk(storage, { + path, + markerPath: getIntentionalResetMarkerPath(path), + normalizeFlaggedStorage: (data) => + normalizeFlaggedStorage(data, { + isRecord, + now: () => Date.now(), + }), + copyFileWithRetry, + renameFileWithRetry, + logWarn: (message, details) => { + log.warn(message, details); + }, + logError: (message, details) => { + log.error(message, details); + }, + }); } export async function saveFlaggedAccounts( @@ -2430,38 +1950,14 @@ export async function saveFlaggedAccounts( export async function clearFlaggedAccounts(): Promise { return withStorageLock(async () => { const path = getFlaggedAccountsPath(); - const markerPath = getIntentionalResetMarkerPath(path); - try { - await fs.writeFile(markerPath, "reset", { - encoding: "utf-8", - mode: 0o600, - }); - } catch (error) { - log.error("Failed to write flagged reset marker", { - path, - markerPath, - error: String(error), - }); - throw error; - } - const backupPaths = - await getAccountsBackupRecoveryCandidatesWithDiscovery(path); - for (const candidate of [path, ...backupPaths, markerPath]) { - try { - await fs.unlink(candidate); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - log.error("Failed to clear flagged account storage", { - path: candidate, - error: String(error), - }); - if (candidate === path) { - throw error; - } - } - } - } + await clearFlaggedAccountsOnDisk({ + path, + markerPath: getIntentionalResetMarkerPath(path), + backupPaths: await getAccountsBackupRecoveryCandidatesWithDiscovery(path), + logError: (message, details) => { + log.error(message, details); + }, + }); }); } @@ -2479,11 +1975,7 @@ export async function exportAccounts( const resolvedPath = resolvePath(filePath); const currentStoragePath = getStoragePath(); - if (!force && existsSync(resolvedPath)) { - throw new Error(`File already exists: ${resolvedPath}`); - } - - const transactionState = transactionSnapshotContext.getStore(); + const transactionState = getTransactionSnapshotState(); const storage = transactionState?.active && transactionState.storagePath === currentStoragePath @@ -2493,30 +1985,14 @@ export async function exportAccounts( : await withAccountStorageTransaction((current) => Promise.resolve(current), ); - if (!storage || storage.accounts.length === 0) { - throw new Error("No accounts to export"); - } - - await fs.mkdir(dirname(resolvedPath), { recursive: true }); - await beforeCommit?.(resolvedPath); - if (!force && existsSync(resolvedPath)) { - throw new Error(`File already exists: ${resolvedPath}`); - } - - const content = JSON.stringify( - { - version: storage.version, - accounts: storage.accounts, - activeIndex: storage.activeIndex, - activeIndexByFamily: storage.activeIndexByFamily, + await exportAccountsToFile({ + resolvedPath, + force, + storage, + beforeCommit, + logInfo: (message, details) => { + log.info(message, details); }, - null, - 2, - ); - await fs.writeFile(resolvedPath, content, { encoding: "utf-8", mode: 0o600 }); - log.info("Exported accounts", { - path: resolvedPath, - count: storage.accounts.length, }); } @@ -2530,59 +2006,29 @@ export async function importAccounts( filePath: string, ): Promise<{ imported: number; total: number; skipped: number }> { const resolvedPath = resolvePath(filePath); - - // Check file exists with friendly error - if (!existsSync(resolvedPath)) { - throw new Error(`Import file not found: ${resolvedPath}`); - } - - const content = await fs.readFile(resolvedPath, "utf-8"); - - let imported: unknown; - try { - imported = JSON.parse(content); - } catch { - throw new Error(`Invalid JSON in import file: ${resolvedPath}`); - } - - const normalized = normalizeAccountStorage(imported); - if (!normalized) { - throw new Error("Invalid account storage format"); - } + const normalized = await readImportFile({ + resolvedPath, + normalizeAccountStorage, + }); const { imported: importedCount, total, skipped: skippedCount, } = await withAccountStorageTransaction(async (existing, persist) => { - const existingAccounts = existing?.accounts ?? []; - const existingActiveIndex = existing?.activeIndex ?? 0; - - const merged = [...existingAccounts, ...normalized.accounts]; - - if (merged.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { - const deduped = deduplicateAccounts(merged); - if (deduped.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { - throw new Error( - `Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts (would have ${deduped.length})`, - ); - } - } - - const deduplicatedAccounts = deduplicateAccounts(merged); + const merged = mergeImportedAccounts({ + existing, + imported: normalized, + maxAccounts: ACCOUNT_LIMITS.MAX_ACCOUNTS, + deduplicateAccounts, + }); - const newStorage: AccountStorageV3 = { - version: 3, - accounts: deduplicatedAccounts, - activeIndex: existingActiveIndex, - activeIndexByFamily: existing?.activeIndexByFamily, + await persist(merged.newStorage); + return { + imported: merged.imported, + total: merged.total, + skipped: merged.skipped, }; - - await persist(newStorage); - - const imported = deduplicatedAccounts.length - existingAccounts.length; - const skipped = normalized.accounts.length - imported; - return { imported, total: deduplicatedAccounts.length, skipped }; }); log.info("Imported accounts", { diff --git a/lib/storage/account-clear.ts b/lib/storage/account-clear.ts new file mode 100644 index 00000000..5f428cd3 --- /dev/null +++ b/lib/storage/account-clear.ts @@ -0,0 +1,38 @@ +import { promises as fs } from "node:fs"; + +export async function clearAccountStorageArtifacts(params: { + path: string; + resetMarkerPath: string; + walPath: string; + backupPaths: string[]; + logError: (message: string, details: Record) => void; +}): Promise { + await fs.writeFile( + params.resetMarkerPath, + JSON.stringify({ version: 1, createdAt: Date.now() }), + { encoding: "utf-8", mode: 0o600 }, + ); + const clearPath = async (targetPath: string): Promise => { + try { + await fs.unlink(targetPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + params.logError("Failed to clear account storage artifact", { + path: targetPath, + error: String(error), + }); + } + } + }; + + try { + await Promise.all([ + clearPath(params.path), + clearPath(params.walPath), + ...params.backupPaths.map(clearPath), + ]); + } catch { + // Individual path cleanup is already best-effort with per-artifact logging. + } +} diff --git a/lib/storage/account-persistence.ts b/lib/storage/account-persistence.ts new file mode 100644 index 00000000..d93cb1b4 --- /dev/null +++ b/lib/storage/account-persistence.ts @@ -0,0 +1,16 @@ +import type { AccountStorageV3 } from "../storage.js"; + +export function cloneAccountStorageForPersistence( + storage: AccountStorageV3 | null | undefined, +): AccountStorageV3 { + return { + version: 3, + accounts: structuredClone(storage?.accounts ?? []), + activeIndex: + typeof storage?.activeIndex === "number" && + Number.isFinite(storage.activeIndex) + ? storage.activeIndex + : 0, + activeIndexByFamily: structuredClone(storage?.activeIndexByFamily ?? {}), + }; +} diff --git a/lib/storage/backup-metadata-builder.ts b/lib/storage/backup-metadata-builder.ts new file mode 100644 index 00000000..f61efbf7 --- /dev/null +++ b/lib/storage/backup-metadata-builder.ts @@ -0,0 +1,113 @@ +import type { BackupMetadata } from "../storage.js"; + +type Snapshot = { + kind: + | "accounts-primary" + | "accounts-wal" + | "accounts-backup" + | "accounts-backup-history" + | "accounts-discovered-backup" + | "flagged-primary" + | "flagged-backup" + | "flagged-backup-history" + | "flagged-discovered-backup"; + path: string; + index?: number; + exists: boolean; + valid: boolean; + bytes?: number; + mtimeMs?: number; + version?: number; + accountCount?: number; + flaggedCount?: number; + schemaErrors?: string[]; +}; + +export async function buildBackupMetadata(params: { + storagePath: string; + flaggedPath: string; + walPath: string; + getAccountsBackupRecoveryCandidatesWithDiscovery: ( + path: string, + ) => Promise; + describeAccountSnapshot: ( + path: string, + kind: + | "accounts-primary" + | "accounts-backup" + | "accounts-backup-history" + | "accounts-discovered-backup", + index?: number, + ) => Promise; + describeAccountsWalSnapshot: (path: string) => Promise; + describeFlaggedSnapshot: ( + path: string, + kind: + | "flagged-primary" + | "flagged-backup" + | "flagged-backup-history" + | "flagged-discovered-backup", + index?: number, + ) => Promise; + buildMetadataSection: ( + storagePath: string, + snapshots: Snapshot[], + ) => { + storagePath: string; + latestValidPath?: string; + snapshotCount: number; + validSnapshotCount: number; + snapshots: Snapshot[]; + }; +}): Promise { + const { + storagePath, + flaggedPath, + walPath, + getAccountsBackupRecoveryCandidatesWithDiscovery, + describeAccountSnapshot, + describeAccountsWalSnapshot, + describeFlaggedSnapshot, + buildMetadataSection, + } = params; + + const accountCandidates = + await getAccountsBackupRecoveryCandidatesWithDiscovery(storagePath); + const accountSnapshots: Snapshot[] = [ + await describeAccountSnapshot(storagePath, "accounts-primary"), + await describeAccountsWalSnapshot(walPath), + ]; + for (const [index, candidate] of accountCandidates.entries()) { + const kind = + candidate === `${storagePath}.bak` + ? "accounts-backup" + : candidate.startsWith(`${storagePath}.bak.`) + ? "accounts-backup-history" + : "accounts-discovered-backup"; + accountSnapshots.push( + await describeAccountSnapshot(candidate, kind, index), + ); + } + + const flaggedCandidates = + await getAccountsBackupRecoveryCandidatesWithDiscovery(flaggedPath); + const flaggedSnapshots: Snapshot[] = [ + await describeFlaggedSnapshot(flaggedPath, "flagged-primary"), + ]; + for (const [index, candidate] of flaggedCandidates.entries()) { + const kind = + candidate === `${flaggedPath}.bak` + ? "flagged-backup" + : candidate.startsWith(`${flaggedPath}.bak.`) + ? "flagged-backup-history" + : "flagged-discovered-backup"; + flaggedSnapshots.push( + await describeFlaggedSnapshot(candidate, kind, index), + ); + } + + return { + accounts: buildMetadataSection(storagePath, accountSnapshots), + flaggedAccounts: buildMetadataSection(flaggedPath, flaggedSnapshots), + }; +} diff --git a/lib/storage/backup-paths.ts b/lib/storage/backup-paths.ts new file mode 100644 index 00000000..8ebafd31 --- /dev/null +++ b/lib/storage/backup-paths.ts @@ -0,0 +1,38 @@ +const ACCOUNTS_BACKUP_SUFFIX = ".bak"; +const ACCOUNTS_WAL_SUFFIX = ".wal"; +const RESET_MARKER_SUFFIX = ".reset-intent"; + +export function getAccountsBackupPath(path: string): string { + return `${path}${ACCOUNTS_BACKUP_SUFFIX}`; +} + +export function getAccountsBackupPathAtIndex( + path: string, + index: number, +): string { + if (index <= 0) { + return getAccountsBackupPath(path); + } + return `${path}${ACCOUNTS_BACKUP_SUFFIX}.${index}`; +} + +export function getAccountsBackupRecoveryCandidates( + path: string, + depth: number, +): string[] { + const candidates: string[] = []; + for (let i = 0; i < depth; i += 1) { + candidates.push(getAccountsBackupPathAtIndex(path, i)); + } + return candidates; +} + +export function getAccountsWalPath(path: string): string { + return `${path}${ACCOUNTS_WAL_SUFFIX}`; +} + +export function getIntentionalResetMarkerPath(path: string): string { + return `${path}${RESET_MARKER_SUFFIX}`; +} + +export { ACCOUNTS_BACKUP_SUFFIX, ACCOUNTS_WAL_SUFFIX, RESET_MARKER_SUFFIX }; diff --git a/lib/storage/backup-restore.ts b/lib/storage/backup-restore.ts new file mode 100644 index 00000000..c51d4750 --- /dev/null +++ b/lib/storage/backup-restore.ts @@ -0,0 +1,71 @@ +import { isAbsolute, relative } from "node:path"; +import type { AccountStorageV3 } from "../storage.js"; + +export async function restoreAccountsFromBackupPath( + path: string, + options: { + persist?: boolean; + backupRoot: string; + realpath: (path: string) => Promise; + loadAccountsFromPath: (path: string) => Promise<{ + normalized: AccountStorageV3 | null; + }>; + saveAccounts: (storage: AccountStorageV3) => Promise; + }, +): Promise { + let resolvedBackupRoot: string; + try { + resolvedBackupRoot = await options.realpath(options.backupRoot); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + throw new Error(`Backup root does not exist: ${options.backupRoot}`); + } + throw error; + } + + let resolvedBackupPath: string; + try { + resolvedBackupPath = await options.realpath(path); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + throw new Error(`Backup file no longer exists: ${path}`); + } + throw error; + } + + const relativePath = relative(resolvedBackupRoot, resolvedBackupPath); + const isInsideBackupRoot = + relativePath.length > 0 && + !relativePath.startsWith("..") && + !isAbsolute(relativePath); + if (!isInsideBackupRoot) { + throw new Error( + `Backup path must stay inside ${resolvedBackupRoot}: ${path}`, + ); + } + + const { normalized } = await (async () => { + try { + return await options.loadAccountsFromPath(resolvedBackupPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + throw new Error(`Backup file no longer exists: ${path}`); + } + throw error; + } + })(); + + if (!normalized || normalized.accounts.length === 0) { + throw new Error( + `Backup does not contain any accounts: ${resolvedBackupPath}`, + ); + } + + if (options.persist !== false) { + await options.saveAccounts(normalized); + } + return normalized; +} diff --git a/lib/storage/fixture-guards.ts b/lib/storage/fixture-guards.ts new file mode 100644 index 00000000..43a609f5 --- /dev/null +++ b/lib/storage/fixture-guards.ts @@ -0,0 +1,38 @@ +import type { AccountMetadataV3, AccountStorageV3 } from "../storage.js"; + +export function looksLikeSyntheticFixtureAccount( + account: AccountMetadataV3, +): boolean { + const email = + typeof account.email === "string" ? account.email.trim().toLowerCase() : ""; + const refreshToken = + typeof account.refreshToken === "string" + ? account.refreshToken.trim().toLowerCase() + : ""; + const accountId = + typeof account.accountId === "string" + ? account.accountId.trim().toLowerCase() + : ""; + if (!/^account\d+@example\.com$/.test(email)) { + return false; + } + const hasSyntheticRefreshToken = + refreshToken.startsWith("fake_refresh") || + /^fake_refresh_token_\d+(_for_testing_only)?$/.test(refreshToken); + if (!hasSyntheticRefreshToken) { + return false; + } + if (accountId.length === 0) { + return true; + } + return /^acc(_|-)?\d+$/.test(accountId); +} + +export function looksLikeSyntheticFixtureStorage( + storage: AccountStorageV3 | null, +): boolean { + if (!storage || storage.accounts.length === 0) return false; + return storage.accounts.every((account) => + looksLikeSyntheticFixtureAccount(account), + ); +} diff --git a/lib/storage/flagged-storage-io.ts b/lib/storage/flagged-storage-io.ts new file mode 100644 index 00000000..1788da2b --- /dev/null +++ b/lib/storage/flagged-storage-io.ts @@ -0,0 +1,165 @@ +import { existsSync, promises as fs } from "node:fs"; +import { dirname } from "node:path"; +import type { FlaggedAccountStorageV1 } from "../storage.js"; + +export async function loadFlaggedAccountsState(params: { + path: string; + legacyPath: string; + resetMarkerPath: string; + normalizeFlaggedStorage: (data: unknown) => FlaggedAccountStorageV1; + saveFlaggedAccounts: (storage: FlaggedAccountStorageV1) => Promise; + logError: (message: string, details: Record) => void; + logInfo: (message: string, details: Record) => void; +}): Promise { + const empty: FlaggedAccountStorageV1 = { version: 1, accounts: [] }; + + try { + const content = await fs.readFile(params.path, "utf-8"); + const data = JSON.parse(content) as unknown; + const loaded = params.normalizeFlaggedStorage(data); + if (existsSync(params.resetMarkerPath)) { + return empty; + } + return loaded; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + params.logError("Failed to load flagged account storage", { + path: params.path, + error: String(error), + }); + return empty; + } + } + + if (!existsSync(params.legacyPath)) { + return empty; + } + + try { + const legacyContent = await fs.readFile(params.legacyPath, "utf-8"); + const legacyData = JSON.parse(legacyContent) as unknown; + const migrated = params.normalizeFlaggedStorage(legacyData); + if (migrated.accounts.length > 0) { + await params.saveFlaggedAccounts(migrated); + } + try { + await fs.unlink(params.legacyPath); + } catch { + // Best effort cleanup. + } + params.logInfo("Migrated legacy flagged account storage", { + from: params.legacyPath, + to: params.path, + accounts: migrated.accounts.length, + }); + return migrated; + } catch (error) { + params.logError("Failed to migrate legacy flagged account storage", { + from: params.legacyPath, + to: params.path, + error: String(error), + }); + return empty; + } +} + +export async function saveFlaggedAccountsUnlockedToDisk( + storage: FlaggedAccountStorageV1, + params: { + path: string; + markerPath: string; + normalizeFlaggedStorage: (data: unknown) => FlaggedAccountStorageV1; + copyFileWithRetry: ( + source: string, + destination: string, + options?: { allowMissingSource?: boolean }, + ) => Promise; + renameFileWithRetry: (source: string, destination: string) => Promise; + logWarn: (message: string, details: Record) => void; + logError: (message: string, details: Record) => void; + }, +): Promise { + const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; + const tempPath = `${params.path}.${uniqueSuffix}.tmp`; + + try { + await fs.mkdir(dirname(params.path), { recursive: true }); + if (existsSync(params.path)) { + try { + await params.copyFileWithRetry(params.path, `${params.path}.bak`, { + allowMissingSource: true, + }); + } catch (backupError) { + params.logWarn("Failed to create flagged backup snapshot", { + path: params.path, + error: String(backupError), + }); + } + } + const content = JSON.stringify( + params.normalizeFlaggedStorage(storage), + null, + 2, + ); + await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 }); + await params.renameFileWithRetry(tempPath, params.path); + try { + await fs.unlink(params.markerPath); + } catch { + // Best effort cleanup. + } + } catch (error) { + try { + await fs.unlink(tempPath); + } catch { + // Ignore cleanup failures. + } + params.logError("Failed to save flagged account storage", { + path: params.path, + error: String(error), + }); + throw error; + } +} + +export async function clearFlaggedAccountsOnDisk(params: { + path: string; + markerPath: string; + backupPaths: string[]; + logError: (message: string, details: Record) => void; +}): Promise { + try { + await fs.writeFile(params.markerPath, "reset", { + encoding: "utf-8", + mode: 0o600, + }); + } catch (error) { + params.logError("Failed to write flagged reset marker", { + path: params.path, + markerPath: params.markerPath, + error: String(error), + }); + throw error; + } + for (const candidate of [ + params.path, + ...params.backupPaths, + params.markerPath, + ]) { + try { + await fs.unlink(candidate); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + params.logError("Failed to clear flagged account storage", { + path: candidate, + error: String(error), + }); + if (candidate === params.path) { + throw error; + } + } + } + } +} diff --git a/lib/storage/flagged-storage.ts b/lib/storage/flagged-storage.ts new file mode 100644 index 00000000..f5e55c26 --- /dev/null +++ b/lib/storage/flagged-storage.ts @@ -0,0 +1,131 @@ +import type { + AccountMetadataV3, + FlaggedAccountMetadataV1, + FlaggedAccountStorageV1, +} from "../storage.js"; + +export function normalizeFlaggedStorage( + data: unknown, + deps: { + isRecord: (value: unknown) => value is Record; + now: () => number; + }, +): FlaggedAccountStorageV1 { + if ( + !deps.isRecord(data) || + data.version !== 1 || + !Array.isArray(data.accounts) + ) { + return { version: 1, accounts: [] }; + } + + const byRefreshToken = new Map(); + for (const rawAccount of data.accounts) { + if (!deps.isRecord(rawAccount)) continue; + const refreshToken = + typeof rawAccount.refreshToken === "string" + ? rawAccount.refreshToken.trim() + : ""; + if (!refreshToken) continue; + + const flaggedAt = + typeof rawAccount.flaggedAt === "number" + ? rawAccount.flaggedAt + : deps.now(); + const isAccountIdSource = ( + value: unknown, + ): value is AccountMetadataV3["accountIdSource"] => + value === "token" || + value === "id_token" || + value === "org" || + value === "manual"; + const isSwitchReason = ( + value: unknown, + ): value is AccountMetadataV3["lastSwitchReason"] => + value === "rate-limit" || + value === "initial" || + value === "rotation" || + value === "best" || + value === "restore"; + const isCooldownReason = ( + value: unknown, + ): value is AccountMetadataV3["cooldownReason"] => + value === "auth-failure" || + value === "network-error" || + value === "rate-limit"; + + let rateLimitResetTimes: + | AccountMetadataV3["rateLimitResetTimes"] + | undefined; + if (deps.isRecord(rawAccount.rateLimitResetTimes)) { + const normalizedRateLimits: Record = {}; + for (const [key, value] of Object.entries( + rawAccount.rateLimitResetTimes, + )) { + if (typeof value === "number") { + normalizedRateLimits[key] = value; + } + } + if (Object.keys(normalizedRateLimits).length > 0) { + rateLimitResetTimes = normalizedRateLimits; + } + } + + const accountIdSource = isAccountIdSource(rawAccount.accountIdSource) + ? rawAccount.accountIdSource + : undefined; + const lastSwitchReason = isSwitchReason(rawAccount.lastSwitchReason) + ? rawAccount.lastSwitchReason + : undefined; + const cooldownReason = isCooldownReason(rawAccount.cooldownReason) + ? rawAccount.cooldownReason + : undefined; + + const normalized: FlaggedAccountMetadataV1 = { + refreshToken, + addedAt: + typeof rawAccount.addedAt === "number" ? rawAccount.addedAt : flaggedAt, + lastUsed: + typeof rawAccount.lastUsed === "number" + ? rawAccount.lastUsed + : flaggedAt, + accountId: + typeof rawAccount.accountId === "string" + ? rawAccount.accountId + : undefined, + accountIdSource, + accountLabel: + typeof rawAccount.accountLabel === "string" + ? rawAccount.accountLabel + : undefined, + email: + typeof rawAccount.email === "string" ? rawAccount.email : undefined, + enabled: + typeof rawAccount.enabled === "boolean" + ? rawAccount.enabled + : undefined, + lastSwitchReason, + rateLimitResetTimes, + coolingDownUntil: + typeof rawAccount.coolingDownUntil === "number" + ? rawAccount.coolingDownUntil + : undefined, + cooldownReason, + flaggedAt, + flaggedReason: + typeof rawAccount.flaggedReason === "string" + ? rawAccount.flaggedReason + : undefined, + lastError: + typeof rawAccount.lastError === "string" + ? rawAccount.lastError + : undefined, + }; + byRefreshToken.set(refreshToken, normalized); + } + + return { + version: 1, + accounts: Array.from(byRefreshToken.values()), + }; +} diff --git a/lib/storage/import-export.ts b/lib/storage/import-export.ts new file mode 100644 index 00000000..32d2827f --- /dev/null +++ b/lib/storage/import-export.ts @@ -0,0 +1,109 @@ +import { existsSync, promises as fs } from "node:fs"; +import { dirname } from "node:path"; +import type { AccountStorageV3 } from "../storage.js"; + +export async function exportAccountsToFile(params: { + resolvedPath: string; + force: boolean; + storage: AccountStorageV3 | null; + beforeCommit?: (resolvedPath: string) => Promise | void; + logInfo: (message: string, details: Record) => void; +}): Promise { + if (!params.force && existsSync(params.resolvedPath)) { + throw new Error(`File already exists: ${params.resolvedPath}`); + } + if (!params.storage || params.storage.accounts.length === 0) { + throw new Error("No accounts to export"); + } + + await fs.mkdir(dirname(params.resolvedPath), { recursive: true }); + await params.beforeCommit?.(params.resolvedPath); + if (!params.force && existsSync(params.resolvedPath)) { + throw new Error(`File already exists: ${params.resolvedPath}`); + } + + const content = JSON.stringify( + { + version: params.storage.version, + accounts: params.storage.accounts, + activeIndex: params.storage.activeIndex, + activeIndexByFamily: params.storage.activeIndexByFamily, + }, + null, + 2, + ); + await fs.writeFile(params.resolvedPath, content, { + encoding: "utf-8", + mode: 0o600, + }); + params.logInfo("Exported accounts", { + path: params.resolvedPath, + count: params.storage.accounts.length, + }); +} + +export async function readImportFile(params: { + resolvedPath: string; + normalizeAccountStorage: (value: unknown) => AccountStorageV3 | null; +}): Promise { + if (!existsSync(params.resolvedPath)) { + throw new Error(`Import file not found: ${params.resolvedPath}`); + } + + const content = await fs.readFile(params.resolvedPath, "utf-8"); + let imported: unknown; + try { + imported = JSON.parse(content); + } catch { + throw new Error(`Invalid JSON in import file: ${params.resolvedPath}`); + } + + const normalized = params.normalizeAccountStorage(imported); + if (!normalized) { + throw new Error("Invalid account storage format"); + } + return normalized; +} + +export function mergeImportedAccounts(params: { + existing: AccountStorageV3 | null; + imported: AccountStorageV3; + maxAccounts: number; + deduplicateAccounts: ( + accounts: AccountStorageV3["accounts"], + ) => AccountStorageV3["accounts"]; +}): { + newStorage: AccountStorageV3; + imported: number; + total: number; + skipped: number; +} { + const existingAccounts = params.existing?.accounts ?? []; + const existingActiveIndex = params.existing?.activeIndex ?? 0; + const merged = [...existingAccounts, ...params.imported.accounts]; + + if (merged.length > params.maxAccounts) { + const deduped = params.deduplicateAccounts(merged); + if (deduped.length > params.maxAccounts) { + throw new Error( + `Import would exceed maximum of ${params.maxAccounts} accounts (would have ${deduped.length})`, + ); + } + } + + const deduplicatedAccounts = params.deduplicateAccounts(merged); + const newStorage: AccountStorageV3 = { + version: 3, + accounts: deduplicatedAccounts, + activeIndex: existingActiveIndex, + activeIndexByFamily: params.existing?.activeIndexByFamily, + }; + const importedCount = deduplicatedAccounts.length - existingAccounts.length; + const skippedCount = params.imported.accounts.length - importedCount; + return { + newStorage, + imported: importedCount, + total: deduplicatedAccounts.length, + skipped: skippedCount, + }; +} diff --git a/lib/storage/metadata-section.ts b/lib/storage/metadata-section.ts new file mode 100644 index 00000000..bbc68291 --- /dev/null +++ b/lib/storage/metadata-section.ts @@ -0,0 +1,29 @@ +export function latestValidSnapshot< + TSnapshot extends { valid: boolean; mtimeMs?: number }, +>(snapshots: TSnapshot[]): TSnapshot | undefined { + return snapshots + .filter((snapshot) => snapshot.valid) + .sort((left, right) => (right.mtimeMs ?? 0) - (left.mtimeMs ?? 0))[0]; +} + +export function buildMetadataSection< + TSnapshot extends { path: string; valid: boolean; mtimeMs?: number }, +>( + storagePath: string, + snapshots: TSnapshot[], +): { + storagePath: string; + latestValidPath?: string; + snapshotCount: number; + validSnapshotCount: number; + snapshots: TSnapshot[]; +} { + const latestValid = latestValidSnapshot(snapshots); + return { + storagePath, + latestValidPath: latestValid?.path, + snapshotCount: snapshots.length, + validSnapshotCount: snapshots.filter((snapshot) => snapshot.valid).length, + snapshots, + }; +} diff --git a/lib/storage/named-backups.ts b/lib/storage/named-backups.ts new file mode 100644 index 00000000..47fb25fa --- /dev/null +++ b/lib/storage/named-backups.ts @@ -0,0 +1,85 @@ +import { promises as fs } from "node:fs"; +import { join } from "node:path"; +import { getNamedBackupRoot } from "../named-backup-export.js"; + +export interface NamedBackupSummary { + path: string; + fileName: string; + accountCount: number; + mtimeMs: number; +} + +export async function collectNamedBackups( + storagePath: string, + deps: { + loadAccountsFromPath: ( + path: string, + ) => Promise<{ normalized: { accounts: unknown[] } | null }>; + logDebug: (message: string, details: Record) => void; + }, +): Promise { + const backupRoot = getNamedBackupRoot(storagePath); + let entries: Array<{ isFile(): boolean; name: string }>; + try { + entries = await fs.readdir(backupRoot, { + withFileTypes: true, + encoding: "utf8", + }); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") return []; + throw error; + } + + const candidates: NamedBackupSummary[] = []; + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!entry.name.toLowerCase().endsWith(".json")) continue; + const candidatePath = join(backupRoot, entry.name); + try { + const statsBefore = await fs.stat(candidatePath); + const { normalized } = await deps.loadAccountsFromPath(candidatePath); + if (!normalized || normalized.accounts.length === 0) continue; + const statsAfter = await fs.stat(candidatePath).catch(() => null); + if (statsAfter && statsAfter.mtimeMs !== statsBefore.mtimeMs) { + deps.logDebug( + "backup file changed between stat and load, mtime may be stale", + { + candidatePath, + fileName: entry.name, + beforeMtimeMs: statsBefore.mtimeMs, + afterMtimeMs: statsAfter.mtimeMs, + }, + ); + } + candidates.push({ + path: candidatePath, + fileName: entry.name, + accountCount: normalized.accounts.length, + mtimeMs: statsBefore.mtimeMs, + }); + } catch (error) { + deps.logDebug( + "Skipping named backup candidate after loadAccountsFromPath/fs.stat failure", + { + candidatePath, + fileName: entry.name, + error: + error instanceof Error + ? { + message: error.message, + stack: error.stack, + } + : String(error), + }, + ); + } + } + + candidates.sort((left, right) => { + const mtimeDelta = right.mtimeMs - left.mtimeMs; + if (mtimeDelta !== 0) return mtimeDelta; + return left.fileName.localeCompare(right.fileName); + }); + return candidates; +} diff --git a/lib/storage/project-migration.ts b/lib/storage/project-migration.ts new file mode 100644 index 00000000..b3379a42 --- /dev/null +++ b/lib/storage/project-migration.ts @@ -0,0 +1,54 @@ +import type { AccountStorageV3 } from "../storage.js"; + +export async function loadNormalizedStorageFromPath( + path: string, + label: string, + deps: { + loadAccountsFromPath: (path: string) => Promise<{ + normalized: AccountStorageV3 | null; + schemaErrors: string[]; + }>; + logWarn: (message: string, details: Record) => void; + }, +): Promise { + try { + const { normalized, schemaErrors } = await deps.loadAccountsFromPath(path); + if (schemaErrors.length > 0) { + deps.logWarn(`${label} schema validation warnings`, { + path, + errors: schemaErrors.slice(0, 5), + }); + } + return normalized; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + deps.logWarn(`Failed to load ${label}`, { + path, + error: String(error), + }); + } + return null; + } +} + +export function mergeStorageForMigration( + current: AccountStorageV3 | null, + incoming: AccountStorageV3, + normalizeAccountStorage: (value: unknown) => AccountStorageV3 | null, +): AccountStorageV3 { + if (!current) { + return incoming; + } + + const merged = normalizeAccountStorage({ + version: 3, + activeIndex: current.activeIndex, + activeIndexByFamily: current.activeIndexByFamily, + accounts: [...current.accounts, ...incoming.accounts], + }); + if (!merged) { + return current; + } + return merged; +} diff --git a/lib/storage/restore-assessment.ts b/lib/storage/restore-assessment.ts new file mode 100644 index 00000000..7d121b05 --- /dev/null +++ b/lib/storage/restore-assessment.ts @@ -0,0 +1,55 @@ +import type { BackupMetadata, RestoreAssessment } from "../storage.js"; + +function findLatestSnapshot(backupMetadata: BackupMetadata) { + return backupMetadata.accounts.latestValidPath + ? backupMetadata.accounts.snapshots.find( + (snapshot) => snapshot.path === backupMetadata.accounts.latestValidPath, + ) + : undefined; +} + +export function buildRestoreAssessment(params: { + storagePath: string; + backupMetadata: BackupMetadata; + hasResetMarker: boolean; +}): RestoreAssessment { + const { storagePath, backupMetadata, hasResetMarker } = params; + if (hasResetMarker) { + return { + storagePath, + restoreEligible: false, + restoreReason: "intentional-reset", + backupMetadata, + }; + } + + const primarySnapshot = backupMetadata.accounts.snapshots.find( + (snapshot) => snapshot.kind === "accounts-primary", + ); + if (!primarySnapshot?.exists) { + return { + storagePath, + restoreEligible: true, + restoreReason: "missing-storage", + latestSnapshot: findLatestSnapshot(backupMetadata), + backupMetadata, + }; + } + + if (primarySnapshot.valid && primarySnapshot.accountCount === 0) { + return { + storagePath, + restoreEligible: true, + restoreReason: "empty-storage", + latestSnapshot: primarySnapshot, + backupMetadata, + }; + } + + return { + storagePath, + restoreEligible: false, + latestSnapshot: findLatestSnapshot(backupMetadata), + backupMetadata, + }; +} diff --git a/lib/storage/storage-parser.ts b/lib/storage/storage-parser.ts new file mode 100644 index 00000000..0251ec0d --- /dev/null +++ b/lib/storage/storage-parser.ts @@ -0,0 +1,40 @@ +import { promises as fs } from "node:fs"; +import { AnyAccountStorageSchema, getValidationErrors } from "../schemas.js"; +import type { AccountStorageV3 } from "../storage.js"; + +export function parseAndNormalizeStorage( + data: unknown, + normalizeAccountStorage: (data: unknown) => AccountStorageV3 | null, + isRecord: (value: unknown) => value is Record, +): { + normalized: AccountStorageV3 | null; + storedVersion: unknown; + schemaErrors: string[]; +} { + const schemaErrors = getValidationErrors(AnyAccountStorageSchema, data); + const normalized = normalizeAccountStorage(data); + const storedVersion = isRecord(data) + ? (data as { version?: unknown }).version + : undefined; + return { normalized, storedVersion, schemaErrors }; +} + +export async function loadAccountsFromPath( + path: string, + deps: { + normalizeAccountStorage: (data: unknown) => AccountStorageV3 | null; + isRecord: (value: unknown) => value is Record; + }, +): Promise<{ + normalized: AccountStorageV3 | null; + storedVersion: unknown; + schemaErrors: string[]; +}> { + const content = await fs.readFile(path, "utf-8"); + const data = JSON.parse(content) as unknown; + return parseAndNormalizeStorage( + data, + deps.normalizeAccountStorage, + deps.isRecord, + ); +} diff --git a/lib/storage/transactions.ts b/lib/storage/transactions.ts new file mode 100644 index 00000000..e3d6a08f --- /dev/null +++ b/lib/storage/transactions.ts @@ -0,0 +1,114 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import type { AccountStorageV3, FlaggedAccountStorageV1 } from "../storage.js"; + +export type TransactionSnapshotState = { + snapshot: AccountStorageV3 | null; + storagePath: string; + active: boolean; +}; + +let storageMutex: Promise = Promise.resolve(); +const transactionSnapshotContext = + new AsyncLocalStorage(); + +export function getTransactionSnapshotState(): + | TransactionSnapshotState + | undefined { + return transactionSnapshotContext.getStore(); +} + +export function withStorageLock(fn: () => Promise): Promise { + const previousMutex = storageMutex; + let releaseLock: () => void; + storageMutex = new Promise((resolve) => { + releaseLock = resolve; + }); + return previousMutex.then(fn).finally(() => releaseLock()); +} + +export async function withAccountStorageTransaction( + handler: ( + current: AccountStorageV3 | null, + persist: (storage: AccountStorageV3) => Promise, + ) => Promise, + deps: { + getStoragePath: () => string; + loadCurrent: () => Promise; + saveAccounts: (storage: AccountStorageV3) => Promise; + }, +): Promise { + return withStorageLock(async () => { + const state: TransactionSnapshotState = { + snapshot: await deps.loadCurrent(), + storagePath: deps.getStoragePath(), + active: true, + }; + const current = state.snapshot; + const persist = async (storage: AccountStorageV3): Promise => { + await deps.saveAccounts(storage); + state.snapshot = storage; + }; + return transactionSnapshotContext.run(state, () => + handler(current, persist), + ); + }); +} + +export async function withAccountAndFlaggedStorageTransaction( + handler: ( + current: AccountStorageV3 | null, + persist: ( + accountStorage: AccountStorageV3, + flaggedStorage: FlaggedAccountStorageV1, + ) => Promise, + ) => Promise, + deps: { + getStoragePath: () => string; + loadCurrent: () => Promise; + saveAccounts: (storage: AccountStorageV3) => Promise; + saveFlaggedAccounts: (storage: FlaggedAccountStorageV1) => Promise; + cloneAccountStorageForPersistence: ( + storage: AccountStorageV3 | null | undefined, + ) => AccountStorageV3; + logRollbackError: (error: unknown, rollbackError: unknown) => void; + }, +): Promise { + return withStorageLock(async () => { + const state: TransactionSnapshotState = { + snapshot: await deps.loadCurrent(), + storagePath: deps.getStoragePath(), + active: true, + }; + const current = state.snapshot; + const persist = async ( + accountStorage: AccountStorageV3, + flaggedStorage: FlaggedAccountStorageV1, + ): Promise => { + const previousAccounts = deps.cloneAccountStorageForPersistence( + state.snapshot, + ); + const nextAccounts = + deps.cloneAccountStorageForPersistence(accountStorage); + await deps.saveAccounts(nextAccounts); + try { + await deps.saveFlaggedAccounts(flaggedStorage); + state.snapshot = nextAccounts; + } catch (error) { + try { + await deps.saveAccounts(previousAccounts); + state.snapshot = previousAccounts; + } catch (rollbackError) { + deps.logRollbackError(error, rollbackError); + throw new AggregateError( + [error, rollbackError], + "Flagged save failed and account storage rollback also failed", + ); + } + throw error; + } + }; + return transactionSnapshotContext.run(state, () => + handler(current, persist), + ); + }); +} diff --git a/test/account-check-helpers.test.ts b/test/account-check-helpers.test.ts new file mode 100644 index 00000000..0b83bbae --- /dev/null +++ b/test/account-check-helpers.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { + clampActiveIndices, + isFlaggableFailure, +} from "../lib/runtime/account-check-helpers.js"; +import type { AccountStorageV3 } from "../lib/storage.js"; + +describe("account check helpers", () => { + it("clamps active indices across families", () => { + const storage: AccountStorageV3 = { + version: 3, + accounts: [ + { refreshToken: "a" }, + { refreshToken: "b" }, + ] as AccountStorageV3["accounts"], + activeIndex: 9, + activeIndexByFamily: { + codex: -2, + "gpt-5.1": 8, + }, + }; + + clampActiveIndices(storage, ["codex", "gpt-5.1"]); + + expect(storage.activeIndex).toBe(1); + expect(storage.activeIndexByFamily).toEqual({ + codex: 0, + "gpt-5.1": 1, + }); + }); + + it("resets empty storage indices", () => { + const storage: AccountStorageV3 = { + version: 3, + accounts: [], + activeIndex: 3, + activeIndexByFamily: { codex: 2 }, + }; + + clampActiveIndices(storage, ["codex"]); + + expect(storage.activeIndex).toBe(0); + expect(storage.activeIndexByFamily).toEqual({}); + }); + + it("flags known refresh token failures", () => { + expect( + isFlaggableFailure({ type: "failed", reason: "missing_refresh" }), + ).toBe(true); + expect( + isFlaggableFailure({ + type: "failed", + statusCode: 400, + message: "invalid_grant: token has been revoked", + }), + ).toBe(true); + expect( + isFlaggableFailure({ + type: "failed", + statusCode: 400, + message: "different bad request", + }), + ).toBe(false); + }); +}); diff --git a/test/account-clear.test.ts b/test/account-clear.test.ts new file mode 100644 index 00000000..d2edfc8a --- /dev/null +++ b/test/account-clear.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it, vi } from "vitest"; +import { clearAccountStorageArtifacts } from "../lib/storage/account-clear.js"; + +describe("account clear helper", () => { + it("clears primary, wal, and backups after writing marker", async () => { + await expect( + clearAccountStorageArtifacts({ + path: `${process.cwd()}/tmp-accounts.json`, + resetMarkerPath: `${process.cwd()}/tmp-accounts.marker`, + walPath: `${process.cwd()}/tmp-accounts.wal`, + backupPaths: [`${process.cwd()}/tmp-accounts.json.bak`], + logError: vi.fn(), + }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/test/account-persistence.test.ts b/test/account-persistence.test.ts new file mode 100644 index 00000000..c7da5e4f --- /dev/null +++ b/test/account-persistence.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { cloneAccountStorageForPersistence } from "../lib/storage/account-persistence.js"; + +describe("account persistence helper", () => { + it("clones storage and normalizes missing numeric fields", () => { + const original = { + version: 3 as const, + accounts: [{ refreshToken: "a" }], + activeIndex: 2, + activeIndexByFamily: { codex: 1 }, + }; + + const cloned = cloneAccountStorageForPersistence(original); + expect(cloned).toEqual(original); + expect(cloned.accounts).not.toBe(original.accounts); + expect(cloned.activeIndexByFamily).not.toBe(original.activeIndexByFamily); + }); + + it("returns empty normalized storage for null input", () => { + expect(cloneAccountStorageForPersistence(null)).toEqual({ + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }); + }); +}); diff --git a/test/account-selection.test.ts b/test/account-selection.test.ts new file mode 100644 index 00000000..ba0cd91a --- /dev/null +++ b/test/account-selection.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vitest"; +import { resolveAccountSelection } from "../lib/runtime/account-selection.js"; + +describe("account selection helper", () => { + it("prefers explicit env override", () => { + const logInfo = vi.fn(); + const result = resolveAccountSelection( + { access: "a", idToken: "b" }, + { + envAccountId: "acct_override_123456", + logInfo, + getAccountIdCandidates: () => [], + selectBestAccountCandidate: () => null, + }, + ); + + expect(result.accountIdOverride).toBe("acct_override_123456"); + expect(result.accountIdSource).toBe("manual"); + expect(logInfo).toHaveBeenCalled(); + }); + + it("returns unchanged tokens when no candidates exist", () => { + const tokens = { access: "a", idToken: "b" }; + const result = resolveAccountSelection(tokens, { + envAccountId: "", + logInfo: vi.fn(), + getAccountIdCandidates: () => [], + selectBestAccountCandidate: () => null, + }); + + expect(result).toEqual(tokens); + }); + + it("maps candidates to workspaces and chooses a single candidate directly", () => { + const result = resolveAccountSelection( + { access: "a", idToken: "b" }, + { + logInfo: vi.fn(), + getAccountIdCandidates: () => [ + { + accountId: "acct_1", + label: "Primary", + source: "token", + isDefault: true, + }, + ], + selectBestAccountCandidate: () => null, + }, + ); + + expect(result.accountIdOverride).toBe("acct_1"); + expect(result.workspaces).toEqual([ + { id: "acct_1", name: "Primary", enabled: true, isDefault: true }, + ]); + }); + + it("uses best candidate when multiple candidates exist", () => { + const candidates = [ + { accountId: "acct_1", label: "One", source: "token" as const }, + { accountId: "acct_2", label: "Two", source: "org" as const }, + ]; + const result = resolveAccountSelection( + { access: "a", idToken: "b" }, + { + logInfo: vi.fn(), + getAccountIdCandidates: () => candidates, + selectBestAccountCandidate: () => candidates[1], + }, + ); + + expect(result.accountIdOverride).toBe("acct_2"); + expect(result.accountIdSource).toBe("org"); + expect(result.workspaces).toHaveLength(2); + }); +}); diff --git a/test/account-status.test.ts b/test/account-status.test.ts new file mode 100644 index 00000000..668e6da4 --- /dev/null +++ b/test/account-status.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { + formatRateLimitEntry, + getRateLimitResetTimeForFamily, + resolveActiveIndex, +} from "../lib/runtime/account-status.js"; + +describe("account status helpers", () => { + it("resolves active index using family overrides and clamps bounds", () => { + expect( + resolveActiveIndex( + { + activeIndex: 9, + activeIndexByFamily: { codex: 1 }, + accounts: [1, 2, 3], + }, + "codex", + ), + ).toBe(1); + expect( + resolveActiveIndex( + { + activeIndex: 9, + activeIndexByFamily: { codex: 7 }, + accounts: [1, 2, 3], + }, + "codex", + ), + ).toBe(2); + }); + + it("finds the soonest future reset for a family", () => { + const now = 1_000; + const account = { + rateLimitResetTimes: { + codex: 500, + "codex:gpt-5-codex": 5_000, + "codex:gpt-5.1": 2_000, + "gpt-5.1": 9_000, + }, + }; + + expect(getRateLimitResetTimeForFamily(account, now, "codex")).toBe(2_000); + expect(getRateLimitResetTimeForFamily(account, now, "gpt-5.1")).toBe(9_000); + }); + + it("formats rate limit entries with remaining wait time", () => { + const entry = formatRateLimitEntry( + { rateLimitResetTimes: { codex: 5_000 } }, + 1_000, + (ms) => `${ms}ms`, + "codex", + ); + + expect(entry).toBe("resets in 4000ms"); + expect( + formatRateLimitEntry( + { rateLimitResetTimes: { codex: 500 } }, + 1_000, + () => "x", + ), + ).toBeNull(); + }); +}); diff --git a/test/backend-category-helpers.test.ts b/test/backend-category-helpers.test.ts new file mode 100644 index 00000000..8e97309d --- /dev/null +++ b/test/backend-category-helpers.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { + applyBackendCategoryDefaults, + getBackendCategory, + getBackendCategoryInitialFocus, + resolveFocusedBackendNumberKey, +} from "../lib/codex-manager/backend-category-helpers.js"; +import { + BACKEND_CATEGORY_OPTIONS, + BACKEND_DEFAULTS, + BACKEND_NUMBER_OPTION_BY_KEY, + BACKEND_NUMBER_OPTIONS, +} from "../lib/codex-manager/backend-settings-schema.js"; + +describe("backend category helpers", () => { + it("resolves focused number key from available options", () => { + expect( + resolveFocusedBackendNumberKey("fetchTimeoutMs", BACKEND_NUMBER_OPTIONS), + ).toBe("fetchTimeoutMs"); + expect(resolveFocusedBackendNumberKey(null, BACKEND_NUMBER_OPTIONS)).toBe( + BACKEND_NUMBER_OPTIONS[0]?.key ?? "fetchTimeoutMs", + ); + }); + + it("finds categories and computes their initial focus", () => { + const category = getBackendCategory( + "performance-timeouts", + BACKEND_CATEGORY_OPTIONS, + ); + expect(category?.key).toBe("performance-timeouts"); + expect( + category ? getBackendCategoryInitialFocus(category) : null, + ).toBeTruthy(); + expect( + getBackendCategory("refresh-recovery", BACKEND_CATEGORY_OPTIONS)?.key, + ).toBe("refresh-recovery"); + }); + + it("applies category defaults for toggles and numeric settings", () => { + const category = getBackendCategory( + "performance-timeouts", + BACKEND_CATEGORY_OPTIONS, + ); + if (!category) throw new Error("missing performance-timeouts category"); + + const draft = { + ...BACKEND_DEFAULTS, + fetchTimeoutMs: 999, + streamStallTimeoutMs: 999, + networkErrorCooldownMs: 999, + }; + const next = applyBackendCategoryDefaults(draft, category, { + backendDefaults: BACKEND_DEFAULTS, + numberOptionByKey: BACKEND_NUMBER_OPTION_BY_KEY, + }); + + for (const key of category.numberKeys) { + expect(next[key]).toBe(BACKEND_DEFAULTS[key]); + } + for (const key of category.toggleKeys) { + expect(next[key]).toBe(BACKEND_DEFAULTS[key]); + } + }); +}); diff --git a/test/backend-category-prompt.test.ts b/test/backend-category-prompt.test.ts new file mode 100644 index 00000000..9623f4b4 --- /dev/null +++ b/test/backend-category-prompt.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from "vitest"; +import { promptBackendCategorySettingsMenu } from "../lib/codex-manager/backend-category-prompt.js"; + +describe("backend category prompt helper", () => { + it("returns initial draft on immediate back", async () => { + const initial = { fetchTimeoutMs: 1000 }; + const result = await promptBackendCategorySettingsMenu({ + initial, + category: { + key: "performance-timeouts", + label: "Performance", + description: "desc", + toggleKeys: [], + numberKeys: ["fetchTimeoutMs"], + }, + initialFocus: "fetchTimeoutMs", + ui: { theme: { accent: "x" } } as never, + cloneBackendPluginConfig: (config) => ({ ...config }), + buildBackendSettingsPreview: () => ({ label: "Preview", hint: "Hint" }), + highlightPreviewToken: (text) => text, + resolveFocusedBackendNumberKey: () => "fetchTimeoutMs", + clampBackendNumber: (_option, value) => value, + formatBackendNumberValue: (_option, value) => String(value), + formatDashboardSettingState: (enabled) => (enabled ? "on" : "off"), + applyBackendCategoryDefaults: (config) => config, + getBackendCategoryInitialFocus: () => "fetchTimeoutMs", + backendDefaults: { fetchTimeoutMs: 1000 }, + toggleOptionByKey: new Map(), + numberOptionByKey: new Map([ + [ + "fetchTimeoutMs", + { + key: "fetchTimeoutMs", + label: "Fetch timeout", + description: "desc", + min: 100, + step: 100, + }, + ], + ]), + select: async () => ({ type: "back" }), + copy: { + previewHeading: "Preview", + backendToggleHeading: "Toggles", + backendNumberHeading: "Numbers", + backendDecrease: "Decrease", + backendIncrease: "Increase", + backendResetCategory: "Reset", + backendBackToCategories: "Back", + backendCategoryTitle: "Category", + backendCategoryHelp: "Help", + }, + }); + + expect(result).toEqual({ draft: initial, focusKey: "fetchTimeoutMs" }); + }); +}); diff --git a/test/backend-settings-controller.test.ts b/test/backend-settings-controller.test.ts new file mode 100644 index 00000000..1ba9768a --- /dev/null +++ b/test/backend-settings-controller.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from "vitest"; +import { configureBackendSettingsController } from "../lib/codex-manager/backend-settings-controller.js"; +import type { PluginConfig } from "../lib/types.js"; + +function createConfig(): PluginConfig { + return { + fetchTimeoutMs: 1000, + }; +} + +describe("backend settings controller", () => { + it("returns current config in non-interactive mode", async () => { + const current = createConfig(); + const result = await configureBackendSettingsController(current, { + cloneBackendPluginConfig: (config) => ({ ...config }), + loadPluginConfig: () => createConfig(), + promptBackendSettings: vi.fn(), + backendSettingsEqual: (left, right) => + left.fetchTimeoutMs === right.fetchTimeoutMs, + persistBackendConfigSelection: vi.fn(), + isInteractive: () => false, + writeLine: vi.fn(), + }); + + expect(result.fetchTimeoutMs).toBe(1000); + }); + + it("returns current config when prompt is cancelled or unchanged", async () => { + const current = createConfig(); + const deps = { + cloneBackendPluginConfig: (config: PluginConfig) => ({ ...config }), + loadPluginConfig: () => createConfig(), + backendSettingsEqual: (left: PluginConfig, right: PluginConfig) => + left.fetchTimeoutMs === right.fetchTimeoutMs, + persistBackendConfigSelection: vi.fn( + async (config: PluginConfig) => config, + ), + isInteractive: () => true, + writeLine: vi.fn(), + }; + + const cancelled = await configureBackendSettingsController(current, { + ...deps, + promptBackendSettings: async () => null, + }); + expect(cancelled.fetchTimeoutMs).toBe(1000); + + const unchanged = await configureBackendSettingsController(current, { + ...deps, + promptBackendSettings: async () => ({ fetchTimeoutMs: 1000 }), + }); + expect(unchanged.fetchTimeoutMs).toBe(1000); + }); + + it("persists changed backend config", async () => { + const persistBackendConfigSelection = vi.fn( + async (config: PluginConfig) => config, + ); + const result = await configureBackendSettingsController(createConfig(), { + cloneBackendPluginConfig: (config) => ({ ...config }), + loadPluginConfig: () => createConfig(), + promptBackendSettings: async () => ({ fetchTimeoutMs: 2000 }), + backendSettingsEqual: (left, right) => + left.fetchTimeoutMs === right.fetchTimeoutMs, + persistBackendConfigSelection, + isInteractive: () => true, + writeLine: vi.fn(), + }); + + expect(persistBackendConfigSelection).toHaveBeenCalledWith( + { fetchTimeoutMs: 2000 }, + "backend", + ); + expect(result.fetchTimeoutMs).toBe(2000); + }); +}); diff --git a/test/backend-settings-entry.test.ts b/test/backend-settings-entry.test.ts new file mode 100644 index 00000000..73b7f825 --- /dev/null +++ b/test/backend-settings-entry.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from "vitest"; +import { configureBackendSettingsEntry } from "../lib/codex-manager/backend-settings-entry.js"; + +describe("backend settings entry", () => { + it("delegates to backend settings controller with provided deps", async () => { + const configureBackendSettingsController = vi.fn(async () => ({ + fetchTimeoutMs: 2000, + })); + const result = await configureBackendSettingsEntry(undefined, { + configureBackendSettingsController, + cloneBackendPluginConfig: vi.fn((config) => config), + loadPluginConfig: vi.fn(() => ({ fetchTimeoutMs: 1000 })), + promptBackendSettings: vi.fn(), + backendSettingsEqual: vi.fn(() => false), + persistBackendConfigSelection: vi.fn(), + isInteractive: vi.fn(() => true), + writeLine: vi.fn(), + }); + + expect(configureBackendSettingsController).toHaveBeenCalled(); + expect(result).toEqual({ fetchTimeoutMs: 2000 }); + }); +}); diff --git a/test/backup-metadata-builder.test.ts b/test/backup-metadata-builder.test.ts new file mode 100644 index 00000000..1628a67f --- /dev/null +++ b/test/backup-metadata-builder.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildBackupMetadata } from "../lib/storage/backup-metadata-builder.js"; + +describe("backup metadata builder", () => { + it("builds account and flagged metadata sections from discovered snapshots", async () => { + const buildMetadataSection = vi.fn( + (storagePath: string, snapshots: Array<{ path: string }>) => ({ + storagePath, + latestValidPath: snapshots.at(-1)?.path, + snapshotCount: snapshots.length, + validSnapshotCount: snapshots.length, + snapshots, + }), + ); + + const result = await buildBackupMetadata({ + storagePath: "/tmp/accounts.json", + flaggedPath: "/tmp/flagged.json", + walPath: "/tmp/accounts.json.wal", + getAccountsBackupRecoveryCandidatesWithDiscovery: async (path) => + path.includes("flagged") + ? ["/tmp/flagged.json.bak"] + : ["/tmp/accounts.json.bak"], + describeAccountSnapshot: async (path, kind, index) => ({ + path, + kind, + index, + exists: true, + valid: true, + }), + describeAccountsWalSnapshot: async (path) => ({ + path, + kind: "accounts-wal", + exists: true, + valid: true, + }), + describeFlaggedSnapshot: async (path, kind, index) => ({ + path, + kind, + index, + exists: true, + valid: true, + }), + buildMetadataSection, + }); + + expect(buildMetadataSection).toHaveBeenCalledTimes(2); + expect(result.accounts.snapshotCount).toBe(3); + expect(result.flaggedAccounts.snapshotCount).toBe(2); + }); +}); diff --git a/test/backup-paths.test.ts b/test/backup-paths.test.ts new file mode 100644 index 00000000..7be85f49 --- /dev/null +++ b/test/backup-paths.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { + getAccountsBackupPath, + getAccountsBackupPathAtIndex, + getAccountsBackupRecoveryCandidates, + getAccountsWalPath, + getIntentionalResetMarkerPath, +} from "../lib/storage/backup-paths.js"; + +describe("backup path helpers", () => { + it("builds backup paths, wal paths, and reset markers", () => { + expect(getAccountsBackupPath("/tmp/accounts.json")).toBe( + "/tmp/accounts.json.bak", + ); + expect(getAccountsBackupPathAtIndex("/tmp/accounts.json", 2)).toBe( + "/tmp/accounts.json.bak.2", + ); + expect(getAccountsWalPath("/tmp/accounts.json")).toBe( + "/tmp/accounts.json.wal", + ); + expect(getIntentionalResetMarkerPath("/tmp/accounts.json")).toBe( + "/tmp/accounts.json.reset-intent", + ); + }); + + it("builds backup recovery candidate list for configured depth", () => { + expect( + getAccountsBackupRecoveryCandidates("/tmp/accounts.json", 3), + ).toEqual([ + "/tmp/accounts.json.bak", + "/tmp/accounts.json.bak.1", + "/tmp/accounts.json.bak.2", + ]); + }); +}); diff --git a/test/backup-restore.test.ts b/test/backup-restore.test.ts new file mode 100644 index 00000000..3e3ada91 --- /dev/null +++ b/test/backup-restore.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from "vitest"; +import { restoreAccountsFromBackupPath } from "../lib/storage/backup-restore.js"; +import type { AccountStorageV3 } from "../lib/storage.js"; + +describe("backup restore helper", () => { + it("rejects backup paths outside the backup root", async () => { + await expect( + restoreAccountsFromBackupPath("/outside/backup.json", { + backupRoot: "/backups", + realpath: async (path) => path, + loadAccountsFromPath: async () => ({ normalized: null }), + saveAccounts: vi.fn(async () => undefined), + }), + ).rejects.toThrow("Backup path must stay inside /backups"); + }); + + it("returns normalized storage and persists by default", async () => { + const normalized: AccountStorageV3 = { + version: 3, + accounts: [{ refreshToken: "a" }] as AccountStorageV3["accounts"], + activeIndex: 0, + activeIndexByFamily: {}, + }; + const saveAccounts = vi.fn(async () => undefined); + + const result = await restoreAccountsFromBackupPath("/backups/good.json", { + backupRoot: "/backups", + realpath: async (path) => path, + loadAccountsFromPath: async () => ({ normalized }), + saveAccounts, + }); + + expect(result).toBe(normalized); + expect(saveAccounts).toHaveBeenCalledWith(normalized); + }); + + it("skips persistence when persist is false and rejects empty backups", async () => { + const saveAccounts = vi.fn(async () => undefined); + + await expect( + restoreAccountsFromBackupPath("/backups/empty.json", { + persist: false, + backupRoot: "/backups", + realpath: async (path) => path, + loadAccountsFromPath: async () => ({ + normalized: { + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }, + }), + saveAccounts, + }), + ).rejects.toThrow("Backup does not contain any accounts"); + + expect(saveAccounts).not.toHaveBeenCalled(); + }); +}); diff --git a/test/browser-oauth-flow.test.ts b/test/browser-oauth-flow.test.ts new file mode 100644 index 00000000..6fcbba83 --- /dev/null +++ b/test/browser-oauth-flow.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from "vitest"; +import { runBrowserOAuthFlow } from "../lib/runtime/browser-oauth-flow.js"; + +describe("browser OAuth flow helper", () => { + it("returns failed when local server cannot start", async () => { + const result = await runBrowserOAuthFlow({ + forceNewLogin: true, + createAuthorizationFlow: async () => ({ + pkce: { verifier: "verifier" }, + state: "state", + url: "https://example.com/oauth", + }), + logInfo: vi.fn(), + redactOAuthUrlForLog: (url) => url, + startLocalOAuthServer: async () => { + throw new Error("no server"); + }, + logDebug: vi.fn(), + openBrowserUrl: vi.fn(), + pluginName: "plugin", + authManualLabel: "manual", + logWarn: vi.fn(), + exchangeAuthorizationCode: vi.fn(), + redirectUri: "http://127.0.0.1:1455/auth/callback", + }); + + expect(result).toEqual({ type: "failed" }); + }); + + it("returns timeout-style failure when no auth code is received", async () => { + const result = await runBrowserOAuthFlow({ + createAuthorizationFlow: async () => ({ + pkce: { verifier: "verifier" }, + state: "state", + url: "https://example.com/oauth", + }), + logInfo: vi.fn(), + redactOAuthUrlForLog: (url) => url, + startLocalOAuthServer: async () => ({ + ready: true, + close: vi.fn(), + waitForCode: async () => null, + }), + logDebug: vi.fn(), + openBrowserUrl: vi.fn(), + pluginName: "plugin", + authManualLabel: "manual", + logWarn: vi.fn(), + exchangeAuthorizationCode: vi.fn(), + redirectUri: "http://127.0.0.1:1455/auth/callback", + }); + + expect(result).toEqual({ + type: "failed", + reason: "unknown", + message: "OAuth callback timeout or cancelled", + }); + }); + + it("exchanges authorization code when callback succeeds", async () => { + const exchangeAuthorizationCode = vi.fn(async () => ({ + type: "success" as const, + access: "access", + refresh: "refresh", + expires: 1, + })); + + const result = await runBrowserOAuthFlow({ + createAuthorizationFlow: async () => ({ + pkce: { verifier: "verifier" }, + state: "state", + url: "https://example.com/oauth", + }), + logInfo: vi.fn(), + redactOAuthUrlForLog: (url) => url, + startLocalOAuthServer: async () => ({ + ready: true, + close: vi.fn(), + waitForCode: async () => ({ code: "auth-code" }), + }), + logDebug: vi.fn(), + openBrowserUrl: vi.fn(), + pluginName: "plugin", + authManualLabel: "manual", + logWarn: vi.fn(), + exchangeAuthorizationCode, + redirectUri: "http://127.0.0.1:1455/auth/callback", + }); + + expect(exchangeAuthorizationCode).toHaveBeenCalledWith( + "auth-code", + "verifier", + "http://127.0.0.1:1455/auth/callback", + ); + expect(result).toEqual({ + type: "success", + access: "access", + refresh: "refresh", + expires: 1, + }); + }); +}); diff --git a/test/dashboard-settings-controller.test.ts b/test/dashboard-settings-controller.test.ts new file mode 100644 index 00000000..eddb4df0 --- /dev/null +++ b/test/dashboard-settings-controller.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from "vitest"; +import { configureDashboardSettingsController } from "../lib/codex-manager/dashboard-settings-controller.js"; +import type { DashboardDisplaySettings } from "../lib/dashboard-settings.js"; + +function createSettings(): DashboardDisplaySettings { + return { + menuShowStatusBadge: true, + }; +} + +describe("dashboard settings controller", () => { + it("returns current settings in non-interactive mode", async () => { + const writeLine = vi.fn(); + const result = await configureDashboardSettingsController(undefined, { + loadDashboardDisplaySettings: async () => createSettings(), + promptSettings: vi.fn(), + settingsEqual: (left, right) => + left.menuShowStatusBadge === right.menuShowStatusBadge, + persistSelection: vi.fn(), + applyUiThemeFromDashboardSettings: vi.fn(), + isInteractive: () => false, + getDashboardSettingsPath: () => "/tmp/settings.json", + writeLine, + }); + + expect(result.menuShowStatusBadge).toBe(true); + expect(writeLine).toHaveBeenCalledWith( + "Settings require interactive mode.", + ); + }); + + it("returns current settings when prompt is cancelled or unchanged", async () => { + const baseDeps = { + loadDashboardDisplaySettings: async () => createSettings(), + settingsEqual: ( + left: DashboardDisplaySettings, + right: DashboardDisplaySettings, + ) => left.menuShowStatusBadge === right.menuShowStatusBadge, + persistSelection: vi.fn( + async (selected: DashboardDisplaySettings) => selected, + ), + applyUiThemeFromDashboardSettings: vi.fn(), + isInteractive: () => true, + getDashboardSettingsPath: () => "/tmp/settings.json", + writeLine: vi.fn(), + }; + + const cancelled = await configureDashboardSettingsController( + createSettings(), + { + ...baseDeps, + promptSettings: async () => null, + }, + ); + expect(cancelled.menuShowStatusBadge).toBe(true); + + const unchanged = await configureDashboardSettingsController( + createSettings(), + { + ...baseDeps, + promptSettings: async () => createSettings(), + }, + ); + expect(unchanged.menuShowStatusBadge).toBe(true); + }); + + it("persists and reapplies theme for changed settings", async () => { + const persistSelection = vi.fn( + async (selected: DashboardDisplaySettings) => selected, + ); + const applyUiThemeFromDashboardSettings = vi.fn(); + + const result = await configureDashboardSettingsController( + createSettings(), + { + loadDashboardDisplaySettings: async () => createSettings(), + promptSettings: async () => ({ menuShowStatusBadge: false }), + settingsEqual: (left, right) => + left.menuShowStatusBadge === right.menuShowStatusBadge, + persistSelection, + applyUiThemeFromDashboardSettings, + isInteractive: () => true, + getDashboardSettingsPath: () => "/tmp/settings.json", + writeLine: vi.fn(), + }, + ); + + expect(persistSelection).toHaveBeenCalledWith({ + menuShowStatusBadge: false, + }); + expect(applyUiThemeFromDashboardSettings).toHaveBeenCalledWith({ + menuShowStatusBadge: false, + }); + expect(result.menuShowStatusBadge).toBe(false); + }); +}); diff --git a/test/experimental-settings-prompt.test.ts b/test/experimental-settings-prompt.test.ts new file mode 100644 index 00000000..d6b80ef8 --- /dev/null +++ b/test/experimental-settings-prompt.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from "vitest"; +import { promptExperimentalSettingsMenu } from "../lib/codex-manager/experimental-settings-prompt.js"; + +describe("experimental settings prompt", () => { + it("returns null when not interactive", async () => { + const result = await promptExperimentalSettingsMenu({ + initialConfig: { proactiveRefreshGuardian: false }, + isInteractive: () => false, + ui: { theme: {} } as never, + cloneBackendPluginConfig: (config) => ({ ...config }), + select: vi.fn(), + getExperimentalSelectOptions: vi.fn(() => ({})), + mapExperimentalMenuHotkey: vi.fn(), + mapExperimentalStatusHotkey: vi.fn(), + formatDashboardSettingState: (enabled) => (enabled ? "on" : "off"), + copy: { + experimentalSync: "Sync", + experimentalBackup: "Backup", + experimentalRefreshGuard: "Guard", + experimentalRefreshInterval: "Interval", + experimentalDecreaseInterval: "Dec", + experimentalIncreaseInterval: "Inc", + saveAndBack: "Save", + backNoSave: "Back", + experimentalHelpMenu: "help", + experimentalBackupPrompt: "name", + back: "Back", + experimentalHelpStatus: "status", + experimentalApplySync: "Apply", + experimentalHelpPreview: "preview", + }, + input: process.stdin, + output: process.stdout, + runNamedBackupExport: vi.fn(), + loadAccounts: vi.fn(), + loadExperimentalSyncTarget: vi.fn(), + planOcChatgptSync: vi.fn(), + applyOcChatgptSync: vi.fn(), + getTargetKind: vi.fn(), + getTargetDestination: vi.fn(), + getTargetDetection: vi.fn(), + getTargetErrorMessage: vi.fn(), + getPlanKind: vi.fn(), + getPlanBlockedReason: vi.fn(), + getPlanPreview: vi.fn(), + getAppliedLabel: vi.fn(), + }); + + expect(result).toBeNull(); + }); + + it("returns draft on save and toggles guardian", async () => { + const select = vi + .fn() + .mockResolvedValueOnce({ type: "toggle-refresh-guardian" }) + .mockResolvedValueOnce({ type: "save" }); + + const result = await promptExperimentalSettingsMenu({ + initialConfig: { + proactiveRefreshGuardian: false, + proactiveRefreshIntervalMs: 60000, + }, + isInteractive: () => true, + ui: { theme: {} } as never, + cloneBackendPluginConfig: (config) => ({ ...config }), + select, + getExperimentalSelectOptions: vi.fn(() => ({})), + mapExperimentalMenuHotkey: vi.fn(), + mapExperimentalStatusHotkey: vi.fn(), + formatDashboardSettingState: (enabled) => (enabled ? "on" : "off"), + copy: { + experimentalSync: "Sync", + experimentalBackup: "Backup", + experimentalRefreshGuard: "Guard", + experimentalRefreshInterval: "Interval", + experimentalDecreaseInterval: "Dec", + experimentalIncreaseInterval: "Inc", + saveAndBack: "Save", + backNoSave: "Back", + experimentalHelpMenu: "help", + experimentalBackupPrompt: "name", + back: "Back", + experimentalHelpStatus: "status", + experimentalApplySync: "Apply", + experimentalHelpPreview: "preview", + }, + input: process.stdin, + output: process.stdout, + runNamedBackupExport: vi.fn(), + loadAccounts: vi.fn(), + loadExperimentalSyncTarget: vi.fn(), + planOcChatgptSync: vi.fn(), + applyOcChatgptSync: vi.fn(), + getTargetKind: vi.fn(), + getTargetDestination: vi.fn(), + getTargetDetection: vi.fn(), + getTargetErrorMessage: vi.fn(), + getPlanKind: vi.fn(), + getPlanBlockedReason: vi.fn(), + getPlanPreview: vi.fn(), + getAppliedLabel: vi.fn(), + }); + + expect(result).toEqual({ + proactiveRefreshGuardian: true, + proactiveRefreshIntervalMs: 60000, + }); + }); +}); diff --git a/test/experimental-sync-target.test.ts b/test/experimental-sync-target.test.ts new file mode 100644 index 00000000..8d7d5eed --- /dev/null +++ b/test/experimental-sync-target.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from "vitest"; +import { loadExperimentalSyncTargetState } from "../lib/codex-manager/experimental-sync-target.js"; +import type { OcChatgptTargetDetectionResult } from "../lib/oc-chatgpt-target-detection.js"; +import type { AccountStorageV3 } from "../lib/storage.js"; + +function createTargetDetection( + accountPath = "/tmp/target.json", +): Extract { + return { + kind: "target", + descriptor: { + scope: "global", + root: "/tmp", + accountPath, + backupRoot: "/tmp/backups", + source: "explicit", + resolution: "accounts", + }, + }; +} + +describe("experimental sync target helper", () => { + it("returns blocked ambiguous targets without reading storage", async () => { + const readJson = vi.fn(); + const result = await loadExperimentalSyncTargetState({ + detectTarget: () => ({ + kind: "ambiguous", + reason: "multiple roots", + candidates: [], + }), + readJson, + normalizeAccountStorage: vi.fn(), + }); + + expect(result).toEqual({ + kind: "blocked-ambiguous", + detection: { + kind: "ambiguous", + reason: "multiple roots", + candidates: [], + }, + }); + expect(readJson).not.toHaveBeenCalled(); + }); + + it("treats a missing destination file as an empty target", async () => { + const detection = createTargetDetection(); + const result = await loadExperimentalSyncTargetState({ + detectTarget: () => detection, + readJson: async () => { + const error = new Error("missing file") as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + }, + normalizeAccountStorage: vi.fn(), + }); + + expect(result).toEqual({ + kind: "target", + detection, + destination: null, + }); + }); + + it("returns an error when the destination storage cannot be normalized", async () => { + const detection = createTargetDetection(); + const result = await loadExperimentalSyncTargetState({ + detectTarget: () => detection, + readJson: async () => ({ version: 999 }), + normalizeAccountStorage: () => null, + }); + + expect(result).toEqual({ + kind: "error", + message: "Invalid target account storage format", + }); + }); + + it("returns the normalized destination when target storage is valid", async () => { + const detection = createTargetDetection(); + const normalized: AccountStorageV3 = { + version: 3, + accounts: [], + }; + const normalizeAccountStorage = vi.fn(() => normalized); + const result = await loadExperimentalSyncTargetState({ + detectTarget: () => detection, + readJson: async () => ({ version: 3, accounts: [] }), + normalizeAccountStorage, + }); + + expect(normalizeAccountStorage).toHaveBeenCalledWith({ + version: 3, + accounts: [], + }); + expect(result).toEqual({ + kind: "target", + detection, + destination: normalized, + }); + }); +}); diff --git a/test/failover-config.test.ts b/test/failover-config.test.ts new file mode 100644 index 00000000..b990b34c --- /dev/null +++ b/test/failover-config.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { + parseEnvInt, + parseFailoverMode, +} from "../lib/request/failover-config.js"; + +describe("failover config helpers", () => { + it("parses failover mode with balanced fallback", () => { + expect(parseFailoverMode("aggressive")).toBe("aggressive"); + expect(parseFailoverMode(" conservative ")).toBe("conservative"); + expect(parseFailoverMode("weird")).toBe("balanced"); + expect(parseFailoverMode(undefined)).toBe("balanced"); + }); + + it("parses finite integers from env values", () => { + expect(parseEnvInt("42")).toBe(42); + expect(parseEnvInt("08")).toBe(8); + expect(parseEnvInt(undefined)).toBeUndefined(); + expect(parseEnvInt("abc")).toBeUndefined(); + }); +}); diff --git a/test/fixture-guards.test.ts b/test/fixture-guards.test.ts new file mode 100644 index 00000000..2a1289ef --- /dev/null +++ b/test/fixture-guards.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { + looksLikeSyntheticFixtureAccount, + looksLikeSyntheticFixtureStorage, +} from "../lib/storage/fixture-guards.js"; + +describe("fixture guard helpers", () => { + it("detects synthetic fixture accounts", () => { + expect( + looksLikeSyntheticFixtureAccount({ + email: "account1@example.com", + refreshToken: "fake_refresh_token_1", + accountId: "acc_1", + } as never), + ).toBe(true); + expect( + looksLikeSyntheticFixtureAccount({ + email: "user@example.com", + refreshToken: "real", + } as never), + ).toBe(false); + }); + + it("detects all-synthetic storages only", () => { + expect( + looksLikeSyntheticFixtureStorage({ + version: 3, + accounts: [ + { + email: "account1@example.com", + refreshToken: "fake_refresh_token_1", + accountId: "acc_1", + }, + ], + activeIndex: 0, + activeIndexByFamily: {}, + } as never), + ).toBe(true); + expect( + looksLikeSyntheticFixtureStorage({ + version: 3, + accounts: [ + { + email: "account1@example.com", + refreshToken: "fake_refresh_token_1", + accountId: "acc_1", + }, + { email: "real@example.com", refreshToken: "real" }, + ], + activeIndex: 0, + activeIndexByFamily: {}, + } as never), + ).toBe(false); + }); +}); diff --git a/test/flagged-storage-io.test.ts b/test/flagged-storage-io.test.ts new file mode 100644 index 00000000..4f66c461 --- /dev/null +++ b/test/flagged-storage-io.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vitest"; +import { + clearFlaggedAccountsOnDisk, + loadFlaggedAccountsState, + saveFlaggedAccountsUnlockedToDisk, +} from "../lib/storage/flagged-storage-io.js"; + +describe("flagged storage io helpers", () => { + it("returns empty storage when files are missing", async () => { + const result = await loadFlaggedAccountsState({ + path: "/tmp/flagged.json", + legacyPath: "/tmp/legacy.json", + resetMarkerPath: "/tmp/reset", + normalizeFlaggedStorage: () => ({ + version: 1, + accounts: [{ refreshToken: "x" }], + }), + saveFlaggedAccounts: vi.fn(), + logError: vi.fn(), + logInfo: vi.fn(), + }); + + expect(result).toEqual({ version: 1, accounts: [] }); + }); + + it("writes flagged storage using injected helpers", async () => { + const copyFileWithRetry = vi.fn(async () => undefined); + const renameFileWithRetry = vi.fn(async () => undefined); + await saveFlaggedAccountsUnlockedToDisk( + { version: 1, accounts: [] }, + { + path: `${process.cwd()}/tmp-flagged.json`, + markerPath: `${process.cwd()}/tmp-flagged.marker`, + normalizeFlaggedStorage: (data) => data as never, + copyFileWithRetry, + renameFileWithRetry, + logWarn: vi.fn(), + logError: vi.fn(), + }, + ); + + expect(renameFileWithRetry).toHaveBeenCalled(); + expect(copyFileWithRetry).not.toThrow; + }); + + it("clears flagged account files with best-effort backup cleanup", async () => { + await expect( + clearFlaggedAccountsOnDisk({ + path: `${process.cwd()}/tmp-flagged.json`, + markerPath: `${process.cwd()}/tmp-flagged.marker`, + backupPaths: [`${process.cwd()}/tmp-flagged.json.bak`], + logError: vi.fn(), + }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/test/flagged-storage.test.ts b/test/flagged-storage.test.ts new file mode 100644 index 00000000..51fa4262 --- /dev/null +++ b/test/flagged-storage.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { normalizeFlaggedStorage } from "../lib/storage/flagged-storage.js"; + +const isRecord = (value: unknown): value is Record => + !!value && typeof value === "object" && !Array.isArray(value); + +describe("flagged storage helper", () => { + it("returns empty storage for invalid payloads", () => { + expect(normalizeFlaggedStorage(null, { isRecord, now: () => 1 })).toEqual({ + version: 1, + accounts: [], + }); + }); + + it("deduplicates by refresh token and normalizes fields", () => { + const result = normalizeFlaggedStorage( + { + version: 1, + accounts: [ + { refreshToken: "token-1", flaggedAt: 10, accountIdSource: "token" }, + { refreshToken: "token-1", flaggedAt: 20, lastError: "oops" }, + ], + }, + { isRecord, now: () => 99 }, + ); + + expect(result.accounts).toHaveLength(1); + expect(result.accounts[0]?.refreshToken).toBe("token-1"); + expect(result.accounts[0]?.lastError).toBe("oops"); + }); +}); diff --git a/test/import-export.test.ts b/test/import-export.test.ts new file mode 100644 index 00000000..2ae3392c --- /dev/null +++ b/test/import-export.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; +import { + exportAccountsToFile, + mergeImportedAccounts, + readImportFile, +} from "../lib/storage/import-export.js"; + +describe("import export helpers", () => { + it("merges imported accounts with dedupe guardrails", () => { + const result = mergeImportedAccounts({ + existing: { + version: 3, + accounts: [{ refreshToken: "a" }], + activeIndex: 0, + activeIndexByFamily: {}, + }, + imported: { + version: 3, + accounts: [{ refreshToken: "b" }], + activeIndex: 0, + activeIndexByFamily: {}, + }, + maxAccounts: 10, + deduplicateAccounts: (accounts) => accounts, + }); + + expect(result.total).toBe(2); + expect(result.imported).toBe(1); + }); + + it("throws for invalid import payloads and empty exports", async () => { + await expect( + readImportFile({ + resolvedPath: `${process.cwd()}/missing-import.json`, + normalizeAccountStorage: () => null, + }), + ).rejects.toThrow("Import file not found"); + + await expect( + exportAccountsToFile({ + resolvedPath: `${process.cwd()}/out.json`, + force: true, + storage: null, + logInfo: vi.fn(), + }), + ).rejects.toThrow("No accounts to export"); + }); +}); diff --git a/test/manual-oauth-flow.test.ts b/test/manual-oauth-flow.test.ts new file mode 100644 index 00000000..4c86f689 --- /dev/null +++ b/test/manual-oauth-flow.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildManualOAuthFlow } from "../lib/runtime/manual-oauth-flow.js"; + +describe("manual OAuth flow helper", () => { + it("validates missing code, missing state, and state mismatch", () => { + const flow = buildManualOAuthFlow({ + pkce: { verifier: "verifier" }, + url: "https://example.com/oauth", + expectedState: "expected", + redirectUri: "http://127.0.0.1:1455/auth/callback", + parseAuthorizationInput: (input) => + JSON.parse(input) as { code?: string; state?: string }, + exchangeAuthorizationCode: vi.fn(), + resolveTokenSuccess: (tokens) => tokens, + instructions: "manual", + }); + + expect(flow.validate(JSON.stringify({ state: "expected" }))).toContain( + "No authorization code found", + ); + expect(flow.validate(JSON.stringify({ code: "abc" }))).toContain( + "Missing OAuth state", + ); + expect( + flow.validate(JSON.stringify({ code: "abc", state: "wrong" })), + ).toContain("OAuth state mismatch"); + }); + + it("returns failed result for invalid callback payloads", async () => { + const flow = buildManualOAuthFlow({ + pkce: { verifier: "verifier" }, + url: "https://example.com/oauth", + expectedState: "expected", + redirectUri: "http://127.0.0.1:1455/auth/callback", + parseAuthorizationInput: (input) => + JSON.parse(input) as { code?: string; state?: string }, + exchangeAuthorizationCode: vi.fn(), + resolveTokenSuccess: (tokens) => tokens, + instructions: "manual", + }); + + await expect( + flow.callback(JSON.stringify({ code: "abc" })), + ).resolves.toEqual({ + type: "failed", + reason: "invalid_response", + message: "Missing authorization code or OAuth state", + }); + }); + + it("exchanges code and resolves successful tokens", async () => { + const onSuccess = vi.fn(async () => undefined); + const flow = buildManualOAuthFlow({ + pkce: { verifier: "verifier" }, + url: "https://example.com/oauth", + expectedState: "expected", + redirectUri: "http://127.0.0.1:1455/auth/callback", + parseAuthorizationInput: (input) => + JSON.parse(input) as { code?: string; state?: string }, + exchangeAuthorizationCode: vi.fn(async () => ({ + type: "success" as const, + access: "access", + refresh: "refresh", + expires: 1, + })), + resolveTokenSuccess: (tokens) => ({ + ...tokens, + accountLabel: "Resolved", + }), + onSuccess, + instructions: "manual", + }); + + const result = await flow.callback( + JSON.stringify({ code: "abc", state: "expected" }), + ); + + expect(result).toEqual({ + type: "success", + access: "access", + refresh: "refresh", + expires: 1, + accountLabel: "Resolved", + }); + expect(onSuccess).toHaveBeenCalled(); + }); +}); diff --git a/test/metadata-section.test.ts b/test/metadata-section.test.ts new file mode 100644 index 00000000..8929dc67 --- /dev/null +++ b/test/metadata-section.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { + buildMetadataSection, + latestValidSnapshot, +} from "../lib/storage/metadata-section.js"; + +describe("metadata section helpers", () => { + it("returns the newest valid snapshot by mtime", () => { + const snapshots = [ + { path: "a", valid: true, mtimeMs: 10 }, + { path: "b", valid: false, mtimeMs: 20 }, + { path: "c", valid: true, mtimeMs: 30 }, + ]; + + expect(latestValidSnapshot(snapshots)?.path).toBe("c"); + }); + + it("builds metadata section counts and latest path", () => { + const snapshots = [ + { path: "a", valid: true, mtimeMs: 10 }, + { path: "b", valid: false, mtimeMs: 20 }, + ]; + + expect(buildMetadataSection("/tmp/accounts.json", snapshots)).toEqual({ + storagePath: "/tmp/accounts.json", + latestValidPath: "a", + snapshotCount: 2, + validSnapshotCount: 1, + snapshots, + }); + }); +}); diff --git a/test/project-migration.test.ts b/test/project-migration.test.ts new file mode 100644 index 00000000..f7c53295 --- /dev/null +++ b/test/project-migration.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from "vitest"; +import { + loadNormalizedStorageFromPath, + mergeStorageForMigration, +} from "../lib/storage/project-migration.js"; +import type { AccountStorageV3 } from "../lib/storage.js"; + +describe("project migration helpers", () => { + it("loads normalized storage and reports schema warnings", async () => { + const logWarn = vi.fn(); + const normalized: AccountStorageV3 = { + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }; + + const result = await loadNormalizedStorageFromPath( + "/tmp/a.json", + "legacy", + { + loadAccountsFromPath: async () => ({ + normalized, + schemaErrors: ["bad field"], + }), + logWarn, + }, + ); + + expect(result).toBe(normalized); + expect(logWarn).toHaveBeenCalledWith( + "legacy schema validation warnings", + expect.objectContaining({ path: "/tmp/a.json" }), + ); + }); + + it("returns null for missing storage without logging", async () => { + const logWarn = vi.fn(); + const result = await loadNormalizedStorageFromPath( + "/tmp/missing.json", + "legacy", + { + loadAccountsFromPath: async () => { + const error = new Error("missing") as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + }, + logWarn, + }, + ); + + expect(result).toBeNull(); + expect(logWarn).not.toHaveBeenCalled(); + }); + + it("merges storages through normalizeAccountStorage and preserves current on invalid merge", () => { + const current: AccountStorageV3 = { + version: 3, + accounts: [{ refreshToken: "a" }] as AccountStorageV3["accounts"], + activeIndex: 0, + activeIndexByFamily: {}, + }; + const incoming: AccountStorageV3 = { + version: 3, + accounts: [{ refreshToken: "b" }] as AccountStorageV3["accounts"], + activeIndex: 0, + activeIndexByFamily: {}, + }; + + const normalize = vi.fn((value: unknown) => value as AccountStorageV3); + const merged = mergeStorageForMigration(current, incoming, normalize); + expect(merged.accounts).toHaveLength(2); + + const fallback = mergeStorageForMigration(current, incoming, () => null); + expect(fallback).toBe(current); + }); +}); diff --git a/test/request-init.test.ts b/test/request-init.test.ts new file mode 100644 index 00000000..bf68f6ad --- /dev/null +++ b/test/request-init.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from "vitest"; +import { + normalizeRequestInit, + parseRequestBodyFromInit, +} from "../lib/request/request-init.js"; + +describe("request init helpers", () => { + it("normalizes a Request when no init is provided", async () => { + const request = new Request("https://example.com", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ hello: "world" }), + }); + + const normalized = await normalizeRequestInit(request, undefined); + expect(normalized?.method).toBe("POST"); + expect(normalized?.body).toBe(JSON.stringify({ hello: "world" })); + }); + + it("returns provided init unchanged", async () => { + const init = { method: "GET" }; + await expect( + normalizeRequestInit("https://example.com", init), + ).resolves.toBe(init); + }); + + it("parses multiple body shapes and warns on invalid payloads", async () => { + const logWarn = vi.fn(); + expect(await parseRequestBodyFromInit('{"a":1}', logWarn)).toEqual({ + a: 1, + }); + expect( + await parseRequestBodyFromInit( + new TextEncoder().encode('{"b":2}'), + logWarn, + ), + ).toEqual({ b: 2 }); + expect(await parseRequestBodyFromInit("not json", logWarn)).toEqual({}); + expect(logWarn).toHaveBeenCalled(); + }); +}); diff --git a/test/response-metadata.test.ts b/test/response-metadata.test.ts new file mode 100644 index 00000000..36853f7e --- /dev/null +++ b/test/response-metadata.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + parseRetryAfterHintMs, + sanitizeResponseHeadersForLog, +} from "../lib/request/response-metadata.js"; + +describe("response metadata helpers", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-22T00:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("parses retry-after-ms before other retry headers", () => { + const headers = new Headers({ + "retry-after-ms": "1200", + "retry-after": "30", + }); + + expect(parseRetryAfterHintMs(headers)).toBe(1200); + }); + + it("parses retry-after seconds and caps large values", () => { + const headers = new Headers({ "retry-after": "999999" }); + + expect(parseRetryAfterHintMs(headers)).toBe(300000); + }); + + it("parses retry-after dates and x-ratelimit-reset timestamps", () => { + const dateHeaders = new Headers({ + "retry-after": "Sun, 22 Mar 2026 00:02:00 GMT", + }); + expect(parseRetryAfterHintMs(dateHeaders)).toBe(120000); + + const resetHeaders = new Headers({ + "x-ratelimit-reset": `${Math.floor(Date.now() / 1000) + 45}`, + }); + expect(parseRetryAfterHintMs(resetHeaders)).toBe(45000); + }); + + it("returns null for invalid or non-positive retry hints", () => { + expect( + parseRetryAfterHintMs(new Headers({ "retry-after-ms": "abc" })), + ).toBe(null); + expect( + parseRetryAfterHintMs( + new Headers({ + "x-ratelimit-reset": `${Math.floor(Date.now() / 1000) - 5}`, + }), + ), + ).toBe(null); + }); + + it("sanitizes response headers down to the allowed logging set", () => { + const headers = new Headers({ + "Content-Type": "application/json", + "X-Request-Id": "req_123", + Authorization: "Bearer secret", + Cookie: "session=secret", + "X-RateLimit-Reset": "12345", + }); + + expect(sanitizeResponseHeadersForLog(headers)).toEqual({ + "content-type": "application/json", + "x-request-id": "req_123", + "x-ratelimit-reset": "12345", + }); + }); +}); diff --git a/test/restore-assessment.test.ts b/test/restore-assessment.test.ts new file mode 100644 index 00000000..7d240c11 --- /dev/null +++ b/test/restore-assessment.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { buildRestoreAssessment } from "../lib/storage/restore-assessment.js"; +import type { BackupMetadata } from "../lib/storage.js"; + +function createBackupMetadata( + overrides?: Partial, +): BackupMetadata { + return { + accounts: { + storagePath: "/tmp/accounts.json", + latestValidPath: "/tmp/accounts.json.bak", + snapshotCount: 2, + validSnapshotCount: 1, + snapshots: [ + { + kind: "accounts-primary", + path: "/tmp/accounts.json", + exists: true, + valid: true, + accountCount: 1, + }, + { + kind: "accounts-backup", + path: "/tmp/accounts.json.bak", + exists: true, + valid: true, + accountCount: 2, + }, + ], + }, + flaggedAccounts: { + storagePath: "/tmp/flagged.json", + snapshotCount: 0, + validSnapshotCount: 0, + snapshots: [], + }, + ...overrides, + }; +} + +describe("restore assessment helper", () => { + it("marks intentional reset as not restorable", () => { + const assessment = buildRestoreAssessment({ + storagePath: "/tmp/accounts.json", + backupMetadata: createBackupMetadata(), + hasResetMarker: true, + }); + + expect(assessment.restoreEligible).toBe(false); + expect(assessment.restoreReason).toBe("intentional-reset"); + }); + + it("marks missing primary storage as restorable from latest snapshot", () => { + const assessment = buildRestoreAssessment({ + storagePath: "/tmp/accounts.json", + backupMetadata: createBackupMetadata({ + accounts: { + storagePath: "/tmp/accounts.json", + latestValidPath: "/tmp/accounts.json.bak", + snapshotCount: 2, + validSnapshotCount: 1, + snapshots: [ + { + kind: "accounts-primary", + path: "/tmp/accounts.json", + exists: false, + valid: false, + }, + { + kind: "accounts-backup", + path: "/tmp/accounts.json.bak", + exists: true, + valid: true, + accountCount: 2, + }, + ], + }, + }), + hasResetMarker: false, + }); + + expect(assessment.restoreEligible).toBe(true); + expect(assessment.restoreReason).toBe("missing-storage"); + expect(assessment.latestSnapshot?.path).toBe("/tmp/accounts.json.bak"); + }); + + it("marks empty primary storage as restorable from primary snapshot", () => { + const assessment = buildRestoreAssessment({ + storagePath: "/tmp/accounts.json", + backupMetadata: createBackupMetadata({ + accounts: { + storagePath: "/tmp/accounts.json", + latestValidPath: "/tmp/accounts.json", + snapshotCount: 1, + validSnapshotCount: 1, + snapshots: [ + { + kind: "accounts-primary", + path: "/tmp/accounts.json", + exists: true, + valid: true, + accountCount: 0, + }, + ], + }, + }), + hasResetMarker: false, + }); + + expect(assessment.restoreEligible).toBe(true); + expect(assessment.restoreReason).toBe("empty-storage"); + expect(assessment.latestSnapshot?.kind).toBe("accounts-primary"); + }); +}); diff --git a/test/runtime-services.test.ts b/test/runtime-services.test.ts new file mode 100644 index 00000000..20491340 --- /dev/null +++ b/test/runtime-services.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from "vitest"; +import { + ensureLiveAccountSyncState, + ensureRefreshGuardianState, + ensureSessionAffinityState, +} from "../lib/runtime/runtime-services.js"; + +describe("runtime services helpers", () => { + it("disables and clears live sync when feature is off", async () => { + const stop = vi.fn(); + const result = await ensureLiveAccountSyncState({ + enabled: false, + targetPath: "/tmp/a", + currentSync: { stop, syncToPath: vi.fn() }, + currentPath: "/tmp/old", + createSync: vi.fn(), + registerCleanup: vi.fn(), + logWarn: vi.fn(), + pluginName: "plugin", + }); + + expect(stop).toHaveBeenCalled(); + expect(result).toEqual({ + liveAccountSync: null, + liveAccountSyncPath: null, + }); + }); + + it("creates and switches live sync path when enabled", async () => { + const syncToPath = vi.fn(async () => undefined); + const created = { stop: vi.fn(), syncToPath }; + const result = await ensureLiveAccountSyncState({ + enabled: true, + targetPath: "/tmp/a", + currentSync: null, + currentPath: null, + createSync: vi.fn(() => created), + registerCleanup: vi.fn(), + logWarn: vi.fn(), + pluginName: "plugin", + }); + + expect(syncToPath).toHaveBeenCalledWith("/tmp/a"); + expect(result.liveAccountSync).toBe(created); + expect(result.liveAccountSyncPath).toBe("/tmp/a"); + }); + + it("recreates refresh guardian when config changes and clears when disabled", () => { + const oldGuardian = { stop: vi.fn(), start: vi.fn() }; + const createGuardian = vi.fn(() => ({ stop: vi.fn(), start: vi.fn() })); + + const enabled = ensureRefreshGuardianState({ + enabled: true, + intervalMs: 1000, + bufferMs: 100, + currentGuardian: oldGuardian, + currentConfigKey: "old", + createGuardian, + registerCleanup: vi.fn(), + }); + expect(oldGuardian.stop).toHaveBeenCalled(); + expect(createGuardian).toHaveBeenCalled(); + expect(enabled.refreshGuardianConfigKey).toBe("1000:100"); + + const disabled = ensureRefreshGuardianState({ + enabled: false, + intervalMs: 1000, + bufferMs: 100, + currentGuardian: enabled.refreshGuardian, + currentConfigKey: enabled.refreshGuardianConfigKey, + createGuardian, + registerCleanup: vi.fn(), + }); + expect(disabled.refreshGuardian).toBeNull(); + }); + + it("creates or clears session affinity store based on config", () => { + const createStore = vi.fn((options) => options); + const enabled = ensureSessionAffinityState({ + enabled: true, + ttlMs: 1000, + maxEntries: 10, + currentStore: null, + currentConfigKey: null, + createStore, + }); + expect(enabled.sessionAffinityStore).toEqual({ + ttlMs: 1000, + maxEntries: 10, + }); + + const disabled = ensureSessionAffinityState({ + enabled: false, + ttlMs: 1000, + maxEntries: 10, + currentStore: enabled.sessionAffinityStore, + currentConfigKey: enabled.sessionAffinityConfigKey, + createStore, + }); + expect(disabled).toEqual({ + sessionAffinityStore: null, + sessionAffinityConfigKey: null, + }); + }); +}); diff --git a/test/settings-hub-menu.test.ts b/test/settings-hub-menu.test.ts new file mode 100644 index 00000000..1f7c12f6 --- /dev/null +++ b/test/settings-hub-menu.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { + buildSettingsHubItems, + findSettingsHubInitialCursor, +} from "../lib/codex-manager/settings-hub-menu.js"; + +const copy = { + sectionTitle: "Sections", + accountList: "Account list", + summaryFields: "Summary fields", + behavior: "Behavior", + theme: "Theme", + advancedTitle: "Advanced", + experimental: "Experimental", + backend: "Backend", + exitTitle: "Exit", + back: "Back", +}; + +describe("settings hub menu helpers", () => { + it("builds the expected menu skeleton", () => { + const items = buildSettingsHubItems(copy); + expect(items.map((item) => item.label)).toContain("Account list"); + expect(items.map((item) => item.label)).toContain("Experimental"); + expect(items.at(-1)?.label).toBe("Back"); + }); + + it("finds the initial cursor for selectable actions", () => { + const items = buildSettingsHubItems(copy); + expect(findSettingsHubInitialCursor(items, "account-list")).toBe(1); + expect(findSettingsHubInitialCursor(items, "backend")).toBe(8); + }); +}); diff --git a/test/settings-hub-prompt.test.ts b/test/settings-hub-prompt.test.ts new file mode 100644 index 00000000..29cb66cd --- /dev/null +++ b/test/settings-hub-prompt.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from "vitest"; +import { promptSettingsHubMenu } from "../lib/codex-manager/settings-hub-prompt.js"; + +describe("settings hub prompt helper", () => { + it("returns null when not interactive", async () => { + const result = await promptSettingsHubMenu("account-list", { + isInteractive: () => false, + getUiRuntimeOptions: vi.fn(), + buildItems: vi.fn(), + findInitialCursor: vi.fn(), + select: vi.fn(), + copy: { title: "t", subtitle: "s", help: "h" }, + }); + + expect(result).toBeNull(); + }); + + it("builds prompt options and delegates to select", async () => { + const select = vi.fn(async () => ({ type: "backend" as const })); + const result = await promptSettingsHubMenu("backend", { + isInteractive: () => true, + getUiRuntimeOptions: () => ({ theme: { accent: "x" } }) as never, + buildItems: () => [ + { label: "Backend", value: { type: "backend" as const } }, + ], + findInitialCursor: () => 0, + select, + copy: { title: "t", subtitle: "s", help: "h" }, + }); + + expect(select).toHaveBeenCalled(); + expect(result).toEqual({ type: "backend" }); + }); +}); diff --git a/test/storage-named-backups.test.ts b/test/storage-named-backups.test.ts new file mode 100644 index 00000000..2b70313d --- /dev/null +++ b/test/storage-named-backups.test.ts @@ -0,0 +1,102 @@ +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getNamedBackupRoot } from "../lib/named-backup-export.js"; +import { collectNamedBackups } from "../lib/storage/named-backups.js"; + +async function removeWithRetry(targetPath: string): Promise { + const retryable = new Set(["EBUSY", "EPERM", "ENOTEMPTY", "EACCES"]); + for (let attempt = 0; attempt < 6; attempt += 1) { + try { + await fs.rm(targetPath, { recursive: true, force: true }); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") return; + if (!code || !retryable.has(code) || attempt === 5) throw error; + await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); + } + } +} + +describe("storage named backups helper", () => { + let rootDir = ""; + + beforeEach(async () => { + rootDir = join( + tmpdir(), + `codex-storage-named-backups-${Math.random().toString(36).slice(2)}`, + ); + await fs.mkdir(rootDir, { recursive: true }); + }); + + afterEach(async () => { + await removeWithRetry(rootDir); + }); + + it("returns empty when the backup directory is missing", async () => { + const result = await collectNamedBackups(rootDir, { + loadAccountsFromPath: vi.fn(), + logDebug: vi.fn(), + }); + + expect(result).toEqual([]); + }); + + it("collects only valid non-empty json backups sorted by newest mtime", async () => { + const backupRoot = getNamedBackupRoot(rootDir); + await fs.mkdir(backupRoot, { recursive: true }); + const olderPath = join(backupRoot, "older.json"); + const newerPath = join(backupRoot, "newer.json"); + const ignoredPath = join(backupRoot, "ignored.txt"); + await fs.writeFile(olderPath, "{}", "utf8"); + await fs.writeFile(newerPath, "{}", "utf8"); + await fs.writeFile(ignoredPath, "nope", "utf8"); + const now = Date.now(); + await fs.utimes(olderPath, now / 1000 - 5, now / 1000 - 5); + await fs.utimes(newerPath, now / 1000, now / 1000); + + const result = await collectNamedBackups(rootDir, { + loadAccountsFromPath: async (path) => ({ + normalized: + path === olderPath + ? { accounts: [{ id: "a" }] } + : path === newerPath + ? { accounts: [{ id: "b" }, { id: "c" }] } + : null, + }), + logDebug: vi.fn(), + }); + + expect(result.map((entry) => entry.fileName)).toEqual([ + "newer.json", + "older.json", + ]); + expect(result.map((entry) => entry.accountCount)).toEqual([2, 1]); + }); + + it("logs and skips invalid backup candidates", async () => { + const backupRoot = getNamedBackupRoot(rootDir); + await fs.mkdir(backupRoot, { recursive: true }); + const badPath = join(backupRoot, "bad.json"); + await fs.writeFile(badPath, "{}", "utf8"); + const logDebug = vi.fn(); + + const result = await collectNamedBackups(rootDir, { + loadAccountsFromPath: async () => { + throw new Error("boom"); + }, + logDebug, + }); + + expect(result).toEqual([]); + expect(logDebug).toHaveBeenCalledWith( + "Skipping named backup candidate after loadAccountsFromPath/fs.stat failure", + expect.objectContaining({ + candidatePath: badPath, + fileName: "bad.json", + }), + ); + }); +}); diff --git a/test/storage-parser.test.ts b/test/storage-parser.test.ts new file mode 100644 index 00000000..459e2583 --- /dev/null +++ b/test/storage-parser.test.ts @@ -0,0 +1,41 @@ +import { promises as fs } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { + loadAccountsFromPath, + parseAndNormalizeStorage, +} from "../lib/storage/storage-parser.js"; +import { normalizeAccountStorage } from "../lib/storage.js"; + +describe("storage parser helpers", () => { + it("parses and normalizes record storage payloads", () => { + const result = parseAndNormalizeStorage( + { version: 3, activeIndex: 0, accounts: [] }, + normalizeAccountStorage, + (value): value is Record => + !!value && typeof value === "object" && !Array.isArray(value), + ); + + expect(result.normalized?.version).toBe(3); + expect(result.storedVersion).toBe(3); + expect(Array.isArray(result.schemaErrors)).toBe(true); + }); + + it("loads and parses storage files from disk", async () => { + const filePath = `${process.cwd()}/tmp-storage-parser-test.json`; + await fs.writeFile( + filePath, + JSON.stringify({ version: 3, activeIndex: 0, accounts: [] }), + "utf8", + ); + try { + const result = await loadAccountsFromPath(filePath, { + normalizeAccountStorage, + isRecord: (value): value is Record => + !!value && typeof value === "object" && !Array.isArray(value), + }); + expect(result.normalized?.version).toBe(3); + } finally { + await fs.rm(filePath, { force: true }); + } + }); +}); diff --git a/test/storage-scope.test.ts b/test/storage-scope.test.ts new file mode 100644 index 00000000..ad6ddfff --- /dev/null +++ b/test/storage-scope.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from "vitest"; +import { applyAccountStorageScopeFromConfig } from "../lib/runtime/storage-scope.js"; + +describe("storage scope helper", () => { + it("forces global storage under codex cli sync and warns once", () => { + const setWarningShown = vi.fn(); + const setStoragePath = vi.fn(); + const logWarn = vi.fn(); + + applyAccountStorageScopeFromConfig({} as never, { + getPerProjectAccounts: () => true, + getStorageBackupEnabled: () => true, + setStorageBackupEnabled: vi.fn(), + isCodexCliSyncEnabled: () => true, + getWarningShown: () => false, + setWarningShown, + logWarn, + pluginName: "plugin", + setStoragePath, + cwd: () => "/tmp/project", + }); + + expect(setWarningShown).toHaveBeenCalledWith(true); + expect(setStoragePath).toHaveBeenCalledWith(null); + expect(logWarn).toHaveBeenCalled(); + }); + + it("uses per-project path when sync is disabled", () => { + const setStoragePath = vi.fn(); + + applyAccountStorageScopeFromConfig({} as never, { + getPerProjectAccounts: () => true, + getStorageBackupEnabled: () => false, + setStorageBackupEnabled: vi.fn(), + isCodexCliSyncEnabled: () => false, + getWarningShown: () => false, + setWarningShown: vi.fn(), + logWarn: vi.fn(), + pluginName: "plugin", + setStoragePath, + cwd: () => "/tmp/project", + }); + + expect(setStoragePath).toHaveBeenCalledWith("/tmp/project"); + }); +}); diff --git a/test/transactions.test.ts b/test/transactions.test.ts new file mode 100644 index 00000000..fe78e2e0 --- /dev/null +++ b/test/transactions.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from "vitest"; +import { + getTransactionSnapshotState, + withAccountAndFlaggedStorageTransaction, + withAccountStorageTransaction, +} from "../lib/storage/transactions.js"; + +describe("storage transaction helpers", () => { + it("runs account transaction with current snapshot and persist callback", async () => { + const saved: unknown[] = []; + const result = await withAccountStorageTransaction( + async (current, persist) => { + expect(current?.accounts).toHaveLength(1); + expect(getTransactionSnapshotState()?.active).toBe(true); + await persist({ + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }); + return "ok"; + }, + { + getStoragePath: () => "/tmp/accounts.json", + loadCurrent: async () => ({ + version: 3, + accounts: [{ refreshToken: "a" }], + activeIndex: 0, + activeIndexByFamily: {}, + }), + saveAccounts: async (storage) => { + saved.push(storage); + }, + }, + ); + + expect(result).toBe("ok"); + expect(saved).toHaveLength(1); + }); + + it("rolls back account storage when flagged save fails", async () => { + const saveAccounts = vi.fn(async () => undefined); + await expect( + withAccountAndFlaggedStorageTransaction( + async (_current, persist) => { + await persist( + { + version: 3, + accounts: [{ refreshToken: "new" }], + activeIndex: 0, + activeIndexByFamily: {}, + }, + { version: 1, accounts: [] }, + ); + return "ok"; + }, + { + getStoragePath: () => "/tmp/accounts.json", + loadCurrent: async () => ({ + version: 3, + accounts: [{ refreshToken: "old" }], + activeIndex: 0, + activeIndexByFamily: {}, + }), + saveAccounts, + saveFlaggedAccounts: async () => { + throw new Error("flagged failed"); + }, + cloneAccountStorageForPersistence: (storage) => + storage ?? { + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }, + logRollbackError: vi.fn(), + }, + ), + ).rejects.toThrow("flagged failed"); + + expect(saveAccounts).toHaveBeenCalledTimes(2); + }); +}); diff --git a/test/unified-settings-controller.test.ts b/test/unified-settings-controller.test.ts new file mode 100644 index 00000000..a304718e --- /dev/null +++ b/test/unified-settings-controller.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from "vitest"; +import { configureUnifiedSettingsController } from "../lib/codex-manager/unified-settings-controller.js"; +import type { DashboardDisplaySettings } from "../lib/dashboard-settings.js"; +import type { PluginConfig } from "../lib/types.js"; + +function createSettings(): DashboardDisplaySettings { + return { menuShowStatusBadge: true }; +} + +function createConfig(): PluginConfig { + return { fetchTimeoutMs: 1000 }; +} + +describe("unified settings controller", () => { + it("returns current settings when hub exits immediately", async () => { + const result = await configureUnifiedSettingsController(undefined, { + cloneDashboardSettings: (settings) => ({ ...settings }), + cloneBackendPluginConfig: (config) => ({ ...config }), + loadDashboardDisplaySettings: async () => createSettings(), + loadPluginConfig: () => createConfig(), + applyUiThemeFromDashboardSettings: vi.fn(), + promptSettingsHub: async () => null, + configureDashboardDisplaySettings: async (current) => current, + configureStatuslineSettings: async (current) => current, + promptBehaviorSettings: async () => null, + promptThemeSettings: async () => null, + dashboardSettingsEqual: () => true, + persistDashboardSettingsSelection: vi.fn(), + promptExperimentalSettings: async () => null, + backendSettingsEqual: () => true, + persistBackendConfigSelection: vi.fn(), + configureBackendSettings: async (config) => config, + BEHAVIOR_PANEL_KEYS: [], + THEME_PANEL_KEYS: [], + }); + + expect(result.menuShowStatusBadge).toBe(true); + }); + + it("routes account-list and backend actions through delegates", async () => { + const configureDashboardDisplaySettings = vi.fn(async () => ({ + menuShowStatusBadge: false, + })); + const configureBackendSettings = vi.fn(async (config: PluginConfig) => ({ + ...config, + fetchTimeoutMs: 2000, + })); + const promptSettingsHub = vi + .fn() + .mockResolvedValueOnce({ type: "account-list" }) + .mockResolvedValueOnce({ type: "backend" }) + .mockResolvedValueOnce({ type: "back" }); + + const result = await configureUnifiedSettingsController(createSettings(), { + cloneDashboardSettings: (settings) => ({ ...settings }), + cloneBackendPluginConfig: (config) => ({ ...config }), + loadDashboardDisplaySettings: async () => createSettings(), + loadPluginConfig: () => createConfig(), + applyUiThemeFromDashboardSettings: vi.fn(), + promptSettingsHub, + configureDashboardDisplaySettings, + configureStatuslineSettings: async (current) => current, + promptBehaviorSettings: async () => null, + promptThemeSettings: async () => null, + dashboardSettingsEqual: () => true, + persistDashboardSettingsSelection: vi.fn(), + promptExperimentalSettings: async () => null, + backendSettingsEqual: () => true, + persistBackendConfigSelection: vi.fn(), + configureBackendSettings, + BEHAVIOR_PANEL_KEYS: [], + THEME_PANEL_KEYS: [], + }); + + expect(configureDashboardDisplaySettings).toHaveBeenCalled(); + expect(configureBackendSettings).toHaveBeenCalledWith({ + fetchTimeoutMs: 1000, + }); + expect(result.menuShowStatusBadge).toBe(false); + }); + + it("persists behavior, theme, and experimental changes when selections differ", async () => { + const persistDashboardSettingsSelection = vi.fn( + async (selected: DashboardDisplaySettings) => selected, + ); + const persistBackendConfigSelection = vi.fn( + async (config: PluginConfig) => config, + ); + const applyUiThemeFromDashboardSettings = vi.fn(); + const promptSettingsHub = vi + .fn() + .mockResolvedValueOnce({ type: "behavior" }) + .mockResolvedValueOnce({ type: "theme" }) + .mockResolvedValueOnce({ type: "experimental" }) + .mockResolvedValueOnce({ type: "back" }); + + const result = await configureUnifiedSettingsController(createSettings(), { + cloneDashboardSettings: (settings) => ({ ...settings }), + cloneBackendPluginConfig: (config) => ({ ...config }), + loadDashboardDisplaySettings: async () => createSettings(), + loadPluginConfig: () => createConfig(), + applyUiThemeFromDashboardSettings, + promptSettingsHub, + configureDashboardDisplaySettings: async (current) => current, + configureStatuslineSettings: async (current) => current, + promptBehaviorSettings: async () => ({ menuShowStatusBadge: false }), + promptThemeSettings: async () => ({ menuShowStatusBadge: true }), + dashboardSettingsEqual: (left, right) => + left.menuShowStatusBadge === right.menuShowStatusBadge, + persistDashboardSettingsSelection, + promptExperimentalSettings: async () => ({ fetchTimeoutMs: 2000 }), + backendSettingsEqual: (left, right) => + left.fetchTimeoutMs === right.fetchTimeoutMs, + persistBackendConfigSelection, + configureBackendSettings: async (config) => config, + BEHAVIOR_PANEL_KEYS: ["menuShowStatusBadge"], + THEME_PANEL_KEYS: ["menuShowStatusBadge"], + }); + + expect(persistDashboardSettingsSelection).toHaveBeenCalledTimes(2); + expect(persistBackendConfigSelection).toHaveBeenCalledWith( + { fetchTimeoutMs: 2000 }, + "experimental", + ); + expect(applyUiThemeFromDashboardSettings).toHaveBeenCalled(); + expect(result.menuShowStatusBadge).toBe(true); + }); +}); diff --git a/test/wait-utils.test.ts b/test/wait-utils.test.ts new file mode 100644 index 00000000..73312081 --- /dev/null +++ b/test/wait-utils.test.ts @@ -0,0 +1,46 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createAbortableSleep, + sleepWithCountdown, +} from "../lib/request/wait-utils.js"; + +describe("wait utils", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("resolves abortable sleep after timeout", async () => { + const sleep = createAbortableSleep(); + const promise = sleep(1000); + await vi.advanceTimersByTimeAsync(1000); + await expect(promise).resolves.toBeUndefined(); + }); + + it("rejects abortable sleep when aborted", async () => { + const controller = new AbortController(); + const sleep = createAbortableSleep(controller.signal); + const promise = sleep(1000); + controller.abort(); + await expect(promise).rejects.toThrow("Aborted"); + }); + + it("shows countdown toasts and sleeps in intervals", async () => { + const showToast = vi.fn(async () => undefined); + const sleep = vi.fn(async () => undefined); + await sleepWithCountdown({ + totalMs: 10_000, + message: "Waiting", + sleep, + showToast, + formatWaitTime: (ms) => `${ms}ms`, + toastDurationMs: 9_000, + intervalMs: 5_000, + }); + expect(showToast).toHaveBeenCalled(); + expect(sleep).toHaveBeenCalled(); + }); +}); From f6c00e41b7ca4de826ecb64fa15ee0a28b91fc80 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 04:39:42 +0800 Subject: [PATCH 211/376] test: cover runtime account helper flows --- lib/runtime/account-check.ts | 6 ++-- lib/runtime/verify-flagged.ts | 5 +++- test/runtime-auth-facade.test.ts | 45 +++++++++++++++++++++++++++++ test/runtime-verify-flagged.test.ts | 43 +++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 test/runtime-auth-facade.test.ts create mode 100644 test/runtime-verify-flagged.test.ts diff --git a/lib/runtime/account-check.ts b/lib/runtime/account-check.ts index 69238200..6d1a36d0 100644 --- a/lib/runtime/account-check.ts +++ b/lib/runtime/account-check.ts @@ -1,3 +1,4 @@ +import { maskEmail } from "../logger.js"; import type { ModelFamily } from "../prompts/codex.js"; import type { AccountStorageV3, FlaggedAccountMetadataV1 } from "../storage.js"; import type { AccountIdSource, TokenResult } from "../types.js"; @@ -95,7 +96,8 @@ export async function runRuntimeAccountCheck( for (let i = 0; i < total; i += 1) { const account = workingStorage.accounts[i]; if (!account) continue; - const label = account.email ?? account.accountLabel ?? `Account ${i + 1}`; + const maskedEmail = account.email ? maskEmail(account.email) : undefined; + const label = account.accountLabel ?? maskedEmail ?? `Account ${i + 1}`; if (account.enabled === false) { state.disabled += 1; deps.showLine(`[${i + 1}/${total}] ${label}: DISABLED`); @@ -198,7 +200,7 @@ export async function runRuntimeAccountCheck( ); const flaggedRecord: FlaggedAccountMetadataV1 = { ...account, - flaggedAt: deps.now?.() ?? Date.now(), + flaggedAt: nowMs, flaggedReason: "token-invalid", lastError: message, }; diff --git a/lib/runtime/verify-flagged.ts b/lib/runtime/verify-flagged.ts index efd3dec5..6d90a483 100644 --- a/lib/runtime/verify-flagged.ts +++ b/lib/runtime/verify-flagged.ts @@ -1,3 +1,4 @@ +import { maskEmail } from "../logger.js"; import type { FlaggedAccountMetadataV1 } from "../storage.js"; import type { TokenSuccessWithAccount } from "./account-selection.js"; import { createFlaggedVerificationState } from "./flagged-verify-types.js"; @@ -63,7 +64,8 @@ export async function verifyRuntimeFlaggedAccounts(deps: { for (let i = 0; i < flaggedStorage.accounts.length; i += 1) { const flagged = flaggedStorage.accounts[i]; if (!flagged) continue; - const label = flagged.email ?? flagged.accountLabel ?? `Flagged ${i + 1}`; + const maskedEmail = flagged.email ? maskEmail(flagged.email) : undefined; + const label = flagged.accountLabel ?? maskedEmail ?? `Flagged ${i + 1}`; try { const cached = await deps.lookupCodexCliTokensByEmail(flagged.email); const now = deps.now?.() ?? Date.now(); @@ -126,6 +128,7 @@ export async function verifyRuntimeFlaggedAccounts(deps: { ); } catch (error) { const message = error instanceof Error ? error.message : String(error); + deps.logError?.(`Failed to verify flagged account ${label}: ${message}`); deps.showLine( `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: ERROR (${message.slice(0, 120)})`, ); diff --git a/test/runtime-auth-facade.test.ts b/test/runtime-auth-facade.test.ts new file mode 100644 index 00000000..9ced6a35 --- /dev/null +++ b/test/runtime-auth-facade.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; +import { createAccountManagerReloader, createPersistAccounts, runRuntimeOAuthFlow } from "../lib/runtime/auth-facade.js"; + +describe("runRuntimeOAuthFlow", () => { + it("passes through info logs and prefixes debug/warn logs with the plugin name", async () => { + const logInfo = vi.fn(); + const logDebug = vi.fn(); + const logWarn = vi.fn(); + await runRuntimeOAuthFlow(true, { + runOAuthBrowserFlow: vi.fn(async (input) => { + input.logInfo("info message"); + input.logDebug("debug message"); + input.logWarn("warn message"); + return { type: "failed", reason: "cancelled" }; + }), + manualModeLabel: "manual", + logInfo, + logDebug, + logWarn, + pluginName: "codex-multi-auth", + }); + expect(logInfo).toHaveBeenCalledWith("info message"); + expect(logDebug).toHaveBeenCalledWith("[codex-multi-auth] debug message"); + expect(logWarn).toHaveBeenCalledWith("[codex-multi-auth] warn message"); + }); +}); + +describe("createPersistAccounts", () => { + it("forwards persist dependencies and replaceAll flag", async () => { + const persistAccountPool = vi.fn(async () => {}); + const persistAccounts = createPersistAccounts({ persistAccountPool, withAccountStorageTransaction: vi.fn(), extractAccountId: vi.fn(), extractAccountEmail: vi.fn(), sanitizeEmail: vi.fn(), findMatchingAccountIndex: vi.fn(), MODEL_FAMILIES: ["codex"] }); + const results = [{ refreshToken: "r1" }] as never[]; + await persistAccounts(results, true); + expect(persistAccountPool).toHaveBeenCalledWith(results, true, expect.objectContaining({ MODEL_FAMILIES: ["codex"] })); + }); +}); + +describe("createAccountManagerReloader", () => { + it("forwards auth fallback and current reload state", async () => { + const reloadRuntimeAccountManager = vi.fn(async () => "manager"); + const reloader = createAccountManagerReloader({ reloadRuntimeAccountManager, getReloadInFlight: () => null, loadFromDisk: vi.fn(async () => "manager"), setCachedAccountManager: vi.fn(), setAccountManagerPromise: vi.fn(), setReloadInFlight: vi.fn() }); + await expect(reloader({ type: "oauth", access: "a", refresh: "r", expires: 1 })).resolves.toBe("manager"); + expect(reloadRuntimeAccountManager).toHaveBeenCalledWith(expect.objectContaining({ authFallback: expect.objectContaining({ refresh: "r" }) })); + }); +}); diff --git a/test/runtime-verify-flagged.test.ts b/test/runtime-verify-flagged.test.ts new file mode 100644 index 00000000..7f055dec --- /dev/null +++ b/test/runtime-verify-flagged.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vitest"; +import { verifyRuntimeFlaggedAccounts } from "../lib/runtime/verify-flagged.js"; + +describe("verifyRuntimeFlaggedAccounts", () => { + it("restores accounts from Codex CLI cache and preserves the remainder", async () => { + const persistAccounts = vi.fn(async () => {}); + const saveFlaggedAccounts = vi.fn(async () => {}); + const showLine = vi.fn(); + await verifyRuntimeFlaggedAccounts({ + loadFlaggedAccounts: async () => ({ version: 1, accounts: [ { email: "cached@example.com", refreshToken: "cached-refresh", addedAt: 1, lastUsed: 1 }, { email: "flagged@example.com", refreshToken: "flagged-refresh", addedAt: 1, lastUsed: 1 } ] }), + lookupCodexCliTokensByEmail: async (email) => email === "cached@example.com" ? { accessToken: "access", refreshToken: "new-refresh", expiresAt: Date.now() + 60000 } : null, + queuedRefresh: async () => ({ type: "failed", reason: "invalid_grant", message: "refresh failed" }), + resolveAccountSelection: (tokens) => ({ refreshToken: tokens.refresh, accessToken: tokens.access }) as never, + persistAccounts, + invalidateAccountManagerCache: vi.fn(), + saveFlaggedAccounts, + logInfo: vi.fn(), + showLine, + }); + expect(persistAccounts).toHaveBeenCalledTimes(1); + expect(saveFlaggedAccounts).toHaveBeenCalledWith({ version: 1, accounts: [expect.objectContaining({ refreshToken: "flagged-refresh" })] }); + expect(showLine).toHaveBeenCalledWith(expect.stringContaining("ca***@***.com: RESTORED (Codex CLI cache)")); + }); + + it("logs verification failures through logError and keeps the account flagged", async () => { + const logError = vi.fn(); + const saveFlaggedAccounts = vi.fn(async () => {}); + await verifyRuntimeFlaggedAccounts({ + loadFlaggedAccounts: async () => ({ version: 1, accounts: [{ email: "broken@example.com", refreshToken: "broken-refresh", addedAt: 1, lastUsed: 1 }] }), + lookupCodexCliTokensByEmail: async () => { throw new Error("cache unavailable"); }, + queuedRefresh: async () => ({ type: "failed", reason: "invalid_grant" }), + resolveAccountSelection: () => ({}) as never, + persistAccounts: vi.fn(async () => {}), + invalidateAccountManagerCache: vi.fn(), + saveFlaggedAccounts, + logInfo: vi.fn(), + logError, + showLine: vi.fn(), + }); + expect(logError).toHaveBeenCalledWith(expect.stringContaining("Failed to verify flagged account br***@***.com: cache unavailable")); + expect(saveFlaggedAccounts).toHaveBeenCalledWith({ version: 1, accounts: [expect.objectContaining({ refreshToken: "broken-refresh", lastError: "cache unavailable" })] }); + }); +}); From abd4bcbe851313c3e559aafa9e2594ae80c82bd4 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 04:41:24 +0800 Subject: [PATCH 212/376] ci: exercise node 22 smoke job --- .github/workflows/pr-ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 591fcf30..b2e92639 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -76,5 +76,11 @@ jobs: - name: Run type check run: npm run typecheck + - name: Run tests + run: npm test + + - name: Security audit (CI policy) + run: npm run audit:ci + - name: Build run: npm run build From 7f0e5ce926990b5cfe3f246d89a3f236fc05d2c8 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 04:41:24 +0800 Subject: [PATCH 213/376] test: add runtime account recovery coverage --- test/runtime-account-check.test.ts | 74 +++++++++++++++++++++++++++ test/runtime-session-recovery.test.ts | 36 +++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 test/runtime-account-check.test.ts create mode 100644 test/runtime-session-recovery.test.ts diff --git a/test/runtime-account-check.test.ts b/test/runtime-account-check.test.ts new file mode 100644 index 00000000..a2c2c0cc --- /dev/null +++ b/test/runtime-account-check.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vitest"; +import { runRuntimeAccountCheck } from "../lib/runtime/account-check.js"; + +describe("runRuntimeAccountCheck", () => { + it("reports when there are no accounts to check", async () => { + const showLine = vi.fn(); + await runRuntimeAccountCheck(false, { + hydrateEmails: async (storage) => storage, + loadAccounts: async () => null, + createEmptyStorage: () => ({ version: 3, accounts: [], activeIndex: 0, activeIndexByFamily: {} }), + loadFlaggedAccounts: async () => ({ version: 1, accounts: [] }), + createAccountCheckWorkingState: () => ({ flaggedStorage: { version: 1, accounts: [] }, removeFromActive: new Set(), storageChanged: false, flaggedChanged: false, ok: 0, errors: 0, disabled: 0 }), + lookupCodexCliTokensByEmail: async () => null, + extractAccountId: () => undefined, + shouldUpdateAccountIdFromToken: () => false, + sanitizeEmail: (email) => email, + extractAccountEmail: () => undefined, + queuedRefresh: async () => ({ type: "failed", reason: "invalid_grant" }), + isRuntimeFlaggableFailure: () => false, + fetchCodexQuotaSnapshot: async () => ({ quotaKey: "codex", limits: {} } as never), + resolveRequestAccountId: () => undefined, + formatCodexQuotaLine: () => "quota", + clampRuntimeActiveIndices: vi.fn(), + MODEL_FAMILIES: ["codex"], + saveAccounts: vi.fn(async () => {}), + invalidateAccountManagerCache: vi.fn(), + saveFlaggedAccounts: vi.fn(async () => {}), + showLine, + }); + expect(showLine).toHaveBeenCalledWith("\nNo accounts to check.\n"); + }); + + it("reuses the current time when flagging an invalid refresh token", async () => { + const saveFlaggedAccounts = vi.fn(async () => {}); + const now = vi.fn(() => 1000 + now.mock.calls.length - 1); + + await runRuntimeAccountCheck(true, { + hydrateEmails: async (storage) => storage, + loadAccounts: async () => ({ + version: 3, + accounts: [ + { email: "one@example.com", refreshToken: "refresh-1", accessToken: undefined, addedAt: 1, lastUsed: 1 }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }), + createEmptyStorage: () => ({ version: 3, accounts: [], activeIndex: 0, activeIndexByFamily: {} }), + loadFlaggedAccounts: async () => ({ version: 1, accounts: [] }), + createAccountCheckWorkingState: (flaggedStorage) => ({ flaggedStorage, removeFromActive: new Set(), storageChanged: false, flaggedChanged: false, ok: 0, errors: 0, disabled: 0 }), + lookupCodexCliTokensByEmail: async () => null, + extractAccountId: () => undefined, + shouldUpdateAccountIdFromToken: () => false, + sanitizeEmail: (email) => email, + extractAccountEmail: () => undefined, + queuedRefresh: async () => ({ type: "failed", reason: "invalid_grant", message: "refresh failed" }), + isRuntimeFlaggableFailure: () => true, + fetchCodexQuotaSnapshot: async () => { throw new Error("should not probe quota in deep mode"); }, + resolveRequestAccountId: () => undefined, + formatCodexQuotaLine: () => "quota", + clampRuntimeActiveIndices: vi.fn(), + MODEL_FAMILIES: ["codex"], + saveAccounts: vi.fn(async () => {}), + invalidateAccountManagerCache: vi.fn(), + saveFlaggedAccounts, + now, + showLine: vi.fn(), + }); + + const flaggedStorage = saveFlaggedAccounts.mock.calls[0]?.[0]; + expect(flaggedStorage.accounts).toHaveLength(1); + expect(flaggedStorage.accounts[0]?.flaggedAt).toBe(1000); + expect(now).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/runtime-session-recovery.test.ts b/test/runtime-session-recovery.test.ts new file mode 100644 index 00000000..be1ca6c5 --- /dev/null +++ b/test/runtime-session-recovery.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from "vitest"; + +const createSessionRecoveryHookMock = vi.fn(); +vi.mock("../lib/recovery.js", () => ({ + createSessionRecoveryHook: createSessionRecoveryHookMock, +})); + +describe("createRuntimeSessionRecoveryHook", () => { + it("returns null when disabled", async () => { + const { createRuntimeSessionRecoveryHook } = await import("../lib/runtime/session-recovery.js"); + expect( + createRuntimeSessionRecoveryHook({ + enabled: false, + client: {} as never, + directory: "/tmp/recovery", + autoResume: true, + }), + ).toBeNull(); + }); + + it("forwards typed client context when enabled", async () => { + createSessionRecoveryHookMock.mockReturnValueOnce({ handleSessionRecovery: vi.fn() }); + const client = {} as never; + const { createRuntimeSessionRecoveryHook } = await import("../lib/runtime/session-recovery.js"); + createRuntimeSessionRecoveryHook({ + enabled: true, + client, + directory: "/tmp/recovery", + autoResume: false, + }); + expect(createSessionRecoveryHookMock).toHaveBeenCalledWith( + { client, directory: "/tmp/recovery" }, + { sessionRecovery: true, autoResume: false }, + ); + }); +}); From 25bd3997ca0233729a6f172974493d78a6124119 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 04:44:59 +0800 Subject: [PATCH 214/376] test: cover runtime toast dispatch --- test/index.test.ts | 260 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 259 insertions(+), 1 deletion(-) diff --git a/test/index.test.ts b/test/index.test.ts index fa56f49d..91ffe123 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2,6 +2,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; process.env.CODEX_MULTI_AUTH_EXPOSE_ADMIN_TOOLS = "1"; +const { showRuntimeToastMock } = vi.hoisted(() => ({ + showRuntimeToastMock: vi.fn(), +})); + vi.mock("@codex-ai/plugin/tool", () => { const makeSchema = () => ({ optional: () => makeSchema(), @@ -193,11 +197,22 @@ vi.mock("../lib/recovery.js", () => ({ })); vi.mock("../lib/request/rate-limit-backoff.js", () => ({ - getRateLimitBackoff: () => ({ attempt: 1, delayMs: 1000 }), + getRateLimitBackoff: vi.fn(() => ({ attempt: 1, delayMs: 1000 })), RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS: 5000, resetRateLimitBackoff: vi.fn(), })); +vi.mock("../lib/runtime/toast.js", async () => { + const actual = await vi.importActual( + "../lib/runtime/toast.js", + ); + showRuntimeToastMock.mockImplementation(actual.showRuntimeToast); + return { + ...actual, + showRuntimeToast: showRuntimeToastMock, + }; +}); + vi.mock("../lib/request/fetch-helpers.js", () => ({ extractRequestUrl: (input: unknown) => (typeof input === "string" ? input : String(input)), rewriteUrlForCodex: (url: string) => url, @@ -3437,4 +3452,247 @@ describe("OpenAIOAuthPlugin event handler edge cases", () => { }); }); +describe("OpenAIOAuthPlugin runtime toast forwarding", () => { + const getOAuthAuth = async () => ({ + type: "oauth" as const, + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + multiAccount: true, + }); + + beforeEach(() => { + vi.clearAllMocks(); + mockStorage.accounts = []; + mockStorage.activeIndex = 0; + mockStorage.activeIndexByFamily = {}; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("forwards account selection toast arguments through showRuntimeToast", async () => { + mockStorage.accounts = [ + { accountId: "acc-1", email: "user1@example.com", refreshToken: "refresh-1" }, + { accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = await OpenAIOAuthPlugin({ client: mockClient } as never) as unknown as PluginType; + + await plugin.auth.loader(getOAuthAuth, { options: {}, models: {} }); + showRuntimeToastMock.mockClear(); + + await plugin.event({ + event: { type: "account.select", properties: { index: 1 } }, + }); + + expect(showRuntimeToastMock).toHaveBeenCalledWith( + mockClient, + "Switched to account 2", + "info", + ); + }); + + it("forwards update notification toast arguments through showRuntimeToast", async () => { + const updateCheckerModule = await import("../lib/auto-update-checker.js"); + vi.mocked(updateCheckerModule.checkAndNotify).mockImplementationOnce( + async (notify: (message: string, variant: "info" | "success" | "warning" | "error") => Promise) => { + await notify("Update available", "warning"); + }, + ); + + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = await OpenAIOAuthPlugin({ client: mockClient } as never) as unknown as PluginType; + + await plugin.auth.loader(getOAuthAuth, { options: {}, models: {} }); + + expect(showRuntimeToastMock).toHaveBeenCalledWith( + mockClient, + "Update available", + "warning", + ); + }); + + it("forwards short retry rate-limit toast arguments through showRuntimeToast", async () => { + const { AccountManager } = await import("../lib/accounts.js"); + const fetchHelpersModule = await import("../lib/request/fetch-helpers.js"); + const rateLimitBackoffModule = await import("../lib/request/rate-limit-backoff.js"); + + const markToastShown = vi.fn(); + const manager = { + getAccountCount: () => 1, + getCurrentOrNextForFamilyHybrid: () => ({ + index: 0, + accountId: "acc-1", + email: "alpha@example.com", + refreshToken: "refresh-1", + }), + getCurrentOrNextForFamily: () => ({ + index: 0, + accountId: "acc-1", + email: "alpha@example.com", + refreshToken: "refresh-1", + }), + getCurrentWorkspace: () => null, + getAccountByIndex: () => null, + getAccountsSnapshot: () => [], + isAccountAvailableForFamily: () => true, + toAuthDetails: () => ({ + type: "oauth" as const, + access: "access-token", + refresh: "refresh-1", + expires: Date.now() + 60_000, + }), + hasRefreshToken: () => true, + saveToDiskDebounced: () => {}, + updateFromAuth: () => {}, + clearAuthFailures: () => {}, + incrementAuthFailures: () => 1, + saveToDisk: async () => {}, + markAccountCoolingDown: () => {}, + markRateLimited: () => {}, + markRateLimitedWithReason: () => {}, + consumeToken: () => true, + refundToken: () => {}, + syncCodexCliActiveSelectionForIndex: async () => {}, + markSwitched: () => {}, + removeAccount: () => {}, + recordFailure: () => {}, + recordSuccess: () => {}, + recordRateLimit: () => {}, + getMinWaitTimeForFamily: () => 0, + shouldShowAccountToast: () => true, + markToastShown, + setActiveIndex: () => null, + }; + vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValue(manager as never); + vi.mocked(fetchHelpersModule.handleErrorResponse).mockResolvedValueOnce({ + response: new Response("rate limited", { status: 429 }), + rateLimit: true, + errorBody: "rate limited", + } as never); + vi.mocked(rateLimitBackoffModule.getRateLimitBackoff).mockReturnValueOnce({ + attempt: 2, + delayMs: 1000, + }); + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce(new Response("rate limited", { status: 429 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ content: "ok" }), { status: 200 })); + + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = await OpenAIOAuthPlugin({ client: mockClient } as never) as unknown as PluginType; + const sdk = await plugin.auth.loader(getOAuthAuth, { options: {}, models: {} }); + showRuntimeToastMock.mockClear(); + + vi.useFakeTimers(); + const responsePromise = sdk.fetch!("https://api.openai.com/v1/chat/completions", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + await vi.advanceTimersByTimeAsync(1000); + const response = await responsePromise; + + expect(response.status).toBe(200); + expect(showRuntimeToastMock).toHaveBeenCalledWith( + mockClient, + expect.stringContaining("Rate limited. Retrying in"), + "warning", + { duration: 5000 }, + ); + expect(markToastShown).toHaveBeenCalledWith(0); + }); + + it("forwards persistence error toast arguments through manual OAuth flow", async () => { + const authModule = await import("../lib/auth/auth.js"); + const storageModule = await import("../lib/storage.js"); + vi.mocked(authModule.createAuthorizationFlow).mockResolvedValueOnce({ + pkce: { verifier: "toast-persist-verifier", challenge: "toast-persist-challenge" }, + state: "toast-persist-state", + url: "https://auth.openai.com/test?state=toast-persist-state", + }); + vi.mocked(authModule.exchangeAuthorizationCode).mockResolvedValueOnce({ + type: "success", + access: "access-token", + refresh: "refresh-toast", + expires: Date.now() + 3600_000, + idToken: "id-token", + }); + vi.mocked(storageModule.saveAccounts).mockRejectedValueOnce( + new storageModule.StorageError( + "Write failed", + "Persist hint", + ), + ); + + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = await OpenAIOAuthPlugin({ client: mockClient } as never) as unknown as PluginType; + const manualMethod = plugin.auth.methods[1] as unknown as { + authorize: () => Promise<{ + callback: (input: string) => Promise<{ type: string }>; + }>; + }; + + showRuntimeToastMock.mockClear(); + const flow = await manualMethod.authorize(); + const result = await flow.callback( + "http://127.0.0.1:1455/auth/callback?code=abc123&state=toast-persist-state", + ); + + expect(result.type).toBe("success"); + expect(showRuntimeToastMock).toHaveBeenCalledWith( + mockClient, + "Persist hint", + "error", + { title: "Account Persistence Failed", duration: 10000 }, + ); + }); + + it("forwards OAuth success toast arguments through browser auth flow", async () => { + const authModule = await import("../lib/auth/auth.js"); + const browserModule = await import("../lib/auth/browser.js"); + const serverModule = await import("../lib/auth/server.js"); + vi.mocked(authModule.createAuthorizationFlow).mockResolvedValueOnce({ + pkce: { verifier: "toast-success-verifier", challenge: "toast-success-challenge" }, + state: "toast-success-state", + url: "https://auth.openai.com/test?state=toast-success-state", + }); + vi.mocked(authModule.exchangeAuthorizationCode).mockResolvedValueOnce({ + type: "success", + access: "access-token", + refresh: "refresh-success", + expires: Date.now() + 3600_000, + idToken: "id-token", + }); + vi.mocked(browserModule.openBrowserUrl).mockReturnValue(true); + vi.mocked(serverModule.startLocalOAuthServer).mockResolvedValue({ + ready: true, + close: vi.fn(), + waitForCode: vi.fn(async () => ({ code: "auth-code" })), + }); + + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = await OpenAIOAuthPlugin({ client: mockClient } as never) as unknown as PluginType; + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise; + }; + + showRuntimeToastMock.mockClear(); + await autoMethod.authorize({ loginMode: "fresh", accountCount: "1" }); + + expect(showRuntimeToastMock).toHaveBeenCalledWith( + mockClient, + "Account 1 authenticated", + "success", + ); + }); +}); + From 07921a44e0ed6568e7612a062022088f424b337e Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 04:46:51 +0800 Subject: [PATCH 215/376] fix: clarify runtime auth and provenance handling --- scripts/verify-vendor-provenance.mjs | 11 ++++++++++- test/runtime-auth-facade.test.ts | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/scripts/verify-vendor-provenance.mjs b/scripts/verify-vendor-provenance.mjs index 49fc11b4..a94e8a53 100644 --- a/scripts/verify-vendor-provenance.mjs +++ b/scripts/verify-vendor-provenance.mjs @@ -24,7 +24,16 @@ for (const component of manifest.components) { if (!file?.path || !file?.sha256) { throw new Error(`Invalid file provenance entry in ${component.name}`); } - const content = await readFile(new URL(`../${file.path}`, import.meta.url)); + let content; + try { + content = await readFile(new URL(`../${file.path}`, import.meta.url)); + } catch (error) { + const code = error && typeof error === "object" ? /** @type {{ code?: string }} */ (error).code : undefined; + if (code === "ENOENT") { + throw new Error(`Vendor file not found in ${component.name}: ${file.path} (${code})`); + } + throw error; + } const actual = createHash("sha256").update(content).digest("hex"); if (actual !== file.sha256) { throw new Error( diff --git a/test/runtime-auth-facade.test.ts b/test/runtime-auth-facade.test.ts index 0410d51c..8eca4d76 100644 --- a/test/runtime-auth-facade.test.ts +++ b/test/runtime-auth-facade.test.ts @@ -49,6 +49,24 @@ describe("createPersistAccounts", () => { expect.objectContaining({ MODEL_FAMILIES: ["codex"] }), ); }); + it("keeps replaceAll optional and false by default", async () => { + const persistAccountPool = vi.fn(async () => {}); + const persistAccounts = createPersistAccounts({ + persistAccountPool, + withAccountStorageTransaction: vi.fn(), + extractAccountId: vi.fn(), + extractAccountEmail: vi.fn(), + sanitizeEmail: vi.fn(), + findMatchingAccountIndex: vi.fn(), + MODEL_FAMILIES: ["codex"], + }); + await persistAccounts([{ refreshToken: "r2" }] as never[]); + expect(persistAccountPool).toHaveBeenCalledWith( + expect.any(Array), + false, + expect.objectContaining({ MODEL_FAMILIES: ["codex"] }), + ); + }); }); describe("createAccountManagerReloader", () => { From 4f882dcbc109828e549bf55239138ac1fcd291c5 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 04:46:51 +0800 Subject: [PATCH 216/376] test: cover migration and config error paths --- lib/storage/migration-helpers.ts | 44 ++++++++++++++++++++------------ test/codex-manager-cli.test.ts | 6 ++--- test/migration-helpers.test.ts | 28 ++++++++++++++++++++ 3 files changed, 58 insertions(+), 20 deletions(-) create mode 100644 test/migration-helpers.test.ts diff --git a/lib/storage/migration-helpers.ts b/lib/storage/migration-helpers.ts index 3bc050fb..7c8fc93a 100644 --- a/lib/storage/migration-helpers.ts +++ b/lib/storage/migration-helpers.ts @@ -1,3 +1,4 @@ +import { sleep } from "../utils.js"; import type { AccountStorageV3 } from "../storage.js"; export async function loadNormalizedStorageFromPathOrNull( @@ -9,26 +10,35 @@ export async function loadNormalizedStorageFromPathOrNull( schemaErrors: string[]; }>; logWarn: (message: string, meta: Record) => void; + sleep?: (ms: number) => Promise; }, ): Promise { - try { - const { normalized, schemaErrors } = await deps.loadAccountsFromPath(path); - if (schemaErrors.length > 0) { - deps.logWarn(`${label} schema validation warnings`, { - path, - errors: schemaErrors.slice(0, 5), - }); + for (let attempt = 0; ; attempt += 1) { + try { + const { normalized, schemaErrors } = await deps.loadAccountsFromPath(path); + if (schemaErrors.length > 0) { + deps.logWarn(`${label} schema validation warnings`, { + path, + errors: schemaErrors.slice(0, 5), + }); + } + return normalized; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "EBUSY" || code === "EPERM" || code === "EAGAIN") { + if (attempt < 3) { + await (deps.sleep ?? sleep)(10 * 2 ** attempt); + continue; + } + } + if (code !== "ENOENT") { + deps.logWarn(`Failed to load ${label}`, { + path, + error: String(error), + }); + } + return null; } - return normalized; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - deps.logWarn(`Failed to load ${label}`, { - path, - error: String(error), - }); - } - return null; } } diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 5a0ee782..e4aa7be2 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -721,14 +721,14 @@ describe("codex manager cli commands", () => { expect(errorSpy).toHaveBeenCalledWith("Unknown option: --bogus"); }); - it("errors for unknown config explain args", async () => { + it("errors when auth config is missing a subcommand", async () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "config", "explain", "--bogus"]); + const exitCode = await runCodexMultiAuthCli(["auth", "config"]); expect(exitCode).toBe(1); - expect(errorSpy).toHaveBeenCalledWith("Unknown option: --bogus"); + expect(errorSpy).toHaveBeenCalledWith("Unknown config command: (missing)"); }); it("errors for unknown config subcommands", async () => { diff --git a/test/migration-helpers.test.ts b/test/migration-helpers.test.ts new file mode 100644 index 00000000..a1806428 --- /dev/null +++ b/test/migration-helpers.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from "vitest"; +import { loadNormalizedStorageFromPathOrNull } from "../lib/storage/migration-helpers.js"; + +describe("loadNormalizedStorageFromPathOrNull", () => { + it("retries transient lock errors before succeeding", async () => { + const sleep = vi.fn(async () => {}); + const loadAccountsFromPath = vi + .fn() + .mockRejectedValueOnce(Object.assign(new Error("busy"), { code: "EBUSY" })) + .mockRejectedValueOnce(Object.assign(new Error("again"), { code: "EAGAIN" })) + .mockResolvedValueOnce({ normalized: { version: 3, activeIndex: 0, activeIndexByFamily: {}, accounts: [] }, schemaErrors: [] }); + const result = await loadNormalizedStorageFromPathOrNull("legacy.json", "legacy storage", { loadAccountsFromPath, logWarn: vi.fn(), sleep }); + expect(result).toMatchObject({ version: 3, accounts: [] }); + expect(loadAccountsFromPath).toHaveBeenCalledTimes(3); + expect(sleep).toHaveBeenNthCalledWith(1, 10); + expect(sleep).toHaveBeenNthCalledWith(2, 20); + }); + + it("returns null and logs once after retry budget is exhausted", async () => { + const logWarn = vi.fn(); + const sleep = vi.fn(async () => {}); + const loadAccountsFromPath = vi.fn().mockRejectedValue(Object.assign(new Error("locked"), { code: "EPERM" })); + const result = await loadNormalizedStorageFromPathOrNull("legacy.json", "legacy storage", { loadAccountsFromPath, logWarn, sleep }); + expect(result).toBeNull(); + expect(loadAccountsFromPath).toHaveBeenCalledTimes(4); + expect(logWarn).toHaveBeenCalledTimes(1); + }); +}); From 0c44a4afb303e626b959fe41e2eaf6c1d23bbc9f Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 04:48:28 +0800 Subject: [PATCH 217/376] refactor: share reload account manager deps --- index.ts | 137 +++++++-------------------- lib/runtime/account-manager-cache.ts | 12 +-- test/index.test.ts | 45 +++++++++ 3 files changed, 86 insertions(+), 108 deletions(-) diff --git a/index.ts b/index.ts index fa5a3858..13d9e6d1 100644 --- a/index.ts +++ b/index.ts @@ -318,6 +318,21 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { MODEL_FAMILIES, }); + const makeReloadAccountManagerDeps = (authFallback?: OAuthAuthDetails) => ({ + currentReloadInFlight: accountReloadInFlight, + loadFromDisk: (fallback?: OAuthAuthDetails) => AccountManager.loadFromDisk(fallback), + setCachedAccountManager: (value: AccountManager) => { + cachedAccountManager = value; + }, + setAccountManagerPromise: (value: Promise | null) => { + accountManagerPromise = value; + }, + setReloadInFlight: (value: Promise | null) => { + accountReloadInFlight = value; + }, + authFallback, + }); + const showToast = async ( message: string, variant: "info" | "success" | "warning" | "error" = "success", @@ -459,21 +474,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { currentPath: liveAccountSyncPath, createSync: (onChange, options) => new LiveAccountSync(onChange, options), reloadAccountManagerFromDisk: async (fallback) => - reloadRuntimeAccountManager({ - currentReloadInFlight: accountReloadInFlight, - loadFromDisk: (reloadFallback) => - AccountManager.loadFromDisk(reloadFallback), - setCachedAccountManager: (value) => { - cachedAccountManager = value; - }, - setAccountManagerPromise: (value) => { - accountManagerPromise = value; - }, - setReloadInFlight: (value) => { - accountReloadInFlight = value; - }, - authFallback: fallback, - }), + reloadRuntimeAccountManager( + makeReloadAccountManagerDeps(fallback), + ), getLiveAccountSyncDebounceMs, getLiveAccountSyncPollMs, registerCleanup, @@ -545,20 +548,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { modelFamilies: MODEL_FAMILIES, cachedAccountManager, reloadAccountManagerFromDisk: async () => { - await reloadRuntimeAccountManager({ - currentReloadInFlight: accountReloadInFlight, - loadFromDisk: (fallback) => AccountManager.loadFromDisk(fallback), - setCachedAccountManager: (value) => { - cachedAccountManager = value; - }, - setAccountManagerPromise: (value) => { - accountManagerPromise = value; - }, - setReloadInFlight: (value) => { - accountReloadInFlight = value; - }, - authFallback: undefined, - }); + await reloadRuntimeAccountManager( + makeReloadAccountManagerDeps(), + ); }, setLastCodexCliActiveSyncIndex: (index) => { lastCodexCliActiveSyncIndex = index; @@ -630,37 +622,15 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { try { await ensureLiveAccountSync(pluginConfig, auth); if (!accountManagerPromise) { - await reloadRuntimeAccountManager({ - currentReloadInFlight: accountReloadInFlight, - loadFromDisk: (fallback) => AccountManager.loadFromDisk(fallback), - setCachedAccountManager: (value) => { - cachedAccountManager = value; - }, - setAccountManagerPromise: (value) => { - accountManagerPromise = value; - }, - setReloadInFlight: (value) => { - accountReloadInFlight = value; - }, - authFallback: auth as OAuthAuthDetails, - }); + await reloadRuntimeAccountManager( + makeReloadAccountManagerDeps(auth as OAuthAuthDetails), + ); } const managerPromise = accountManagerPromise ?? - reloadRuntimeAccountManager({ - currentReloadInFlight: accountReloadInFlight, - loadFromDisk: (fallback) => AccountManager.loadFromDisk(fallback), - setCachedAccountManager: (value) => { - cachedAccountManager = value; - }, - setAccountManagerPromise: (value) => { - accountManagerPromise = value; - }, - setReloadInFlight: (value) => { - accountReloadInFlight = value; - }, - authFallback: auth as OAuthAuthDetails, - }); + reloadRuntimeAccountManager( + makeReloadAccountManagerDeps(auth as OAuthAuthDetails), + ); let accountManager = await managerPromise; cachedAccountManager = accountManager; const refreshToken = auth.type === "oauth" ? auth.refresh : ""; @@ -3929,20 +3899,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } if (cachedAccountManager) { - await reloadRuntimeAccountManager({ - currentReloadInFlight: accountReloadInFlight, - loadFromDisk: (fallback) => AccountManager.loadFromDisk(fallback), - setCachedAccountManager: (value) => { - cachedAccountManager = value; - }, - setAccountManagerPromise: (value) => { - accountManagerPromise = value; - }, - setReloadInFlight: (value) => { - accountReloadInFlight = value; - }, - authFallback: undefined, - }); + await reloadRuntimeAccountManager( + makeReloadAccountManagerDeps(), + ); } const label = formatAccountLabel(account, targetIndex); @@ -4518,21 +4477,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } if (cachedAccountManager) { - await reloadRuntimeAccountManager({ - currentReloadInFlight: accountReloadInFlight, - loadFromDisk: (fallback) => - AccountManager.loadFromDisk(fallback), - setCachedAccountManager: (value) => { - cachedAccountManager = value; - }, - setAccountManagerPromise: (value) => { - accountManagerPromise = value; - }, - setReloadInFlight: (value) => { - accountReloadInFlight = value; - }, - authFallback: undefined, - }); + await reloadRuntimeAccountManager( + makeReloadAccountManagerDeps(), + ); } const remaining = storage.accounts.length; @@ -4629,21 +4576,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { await saveAccounts(storage); if (cachedAccountManager) { - await reloadRuntimeAccountManager({ - currentReloadInFlight: accountReloadInFlight, - loadFromDisk: (fallback) => - AccountManager.loadFromDisk(fallback), - setCachedAccountManager: (value) => { - cachedAccountManager = value; - }, - setAccountManagerPromise: (value) => { - accountManagerPromise = value; - }, - setReloadInFlight: (value) => { - accountReloadInFlight = value; - }, - authFallback: undefined, - }); + await reloadRuntimeAccountManager( + makeReloadAccountManagerDeps(), + ); } results.push(""); results.push( diff --git a/lib/runtime/account-manager-cache.ts b/lib/runtime/account-manager-cache.ts index 8094e739..43cd741f 100644 --- a/lib/runtime/account-manager-cache.ts +++ b/lib/runtime/account-manager-cache.ts @@ -8,7 +8,7 @@ export function invalidateRuntimeAccountManagerCache(deps: { deps.setAccountManagerPromise(null); } -export async function reloadRuntimeAccountManager(deps: { +export function reloadRuntimeAccountManager(deps: { currentReloadInFlight: Promise | null; loadFromDisk: (authFallback?: OAuthAuthDetails) => Promise; setCachedAccountManager: (value: TAccountManager) => void; @@ -25,12 +25,10 @@ export async function reloadRuntimeAccountManager(deps: { deps.setCachedAccountManager(reloaded); deps.setAccountManagerPromise(Promise.resolve(reloaded)); return reloaded; - })(); + })().finally(() => { + deps.setReloadInFlight(null); + }); deps.setReloadInFlight(reloadInFlight); - try { - return await reloadInFlight; - } finally { - deps.setReloadInFlight(null); - } + return reloadInFlight; } diff --git a/test/index.test.ts b/test/index.test.ts index fa56f49d..534108b2 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -3437,4 +3437,49 @@ describe("OpenAIOAuthPlugin event handler edge cases", () => { }); }); +describe("reloadRuntimeAccountManager", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("reuses the same in-flight promise for concurrent reloads", async () => { + const runtimeCacheModule = await import("../lib/runtime/account-manager-cache.js"); + const reloadedManager = { name: "manager" }; + let resolveLoad!: (value: typeof reloadedManager) => void; + const loadPromise = new Promise((resolve) => { + resolveLoad = resolve; + }); + const loadFromDisk = vi.fn(async () => loadPromise); + let cachedAccountManager: typeof reloadedManager | null = null; + let accountManagerPromise: Promise | null = null; + let accountReloadInFlight: Promise | null = null; + const makeDeps = () => ({ + currentReloadInFlight: accountReloadInFlight, + loadFromDisk, + setCachedAccountManager: (value: typeof reloadedManager) => { + cachedAccountManager = value; + }, + setAccountManagerPromise: (value: Promise | null) => { + accountManagerPromise = value; + }, + setReloadInFlight: (value: Promise | null) => { + accountReloadInFlight = value; + }, + }); + + const firstReload = runtimeCacheModule.reloadRuntimeAccountManager(makeDeps()); + const secondReload = runtimeCacheModule.reloadRuntimeAccountManager(makeDeps()); + + expect(secondReload).toBe(firstReload); + expect(loadFromDisk).toHaveBeenCalledTimes(1); + + resolveLoad(reloadedManager); + + await expect(firstReload).resolves.toBe(reloadedManager); + expect(cachedAccountManager).toBe(reloadedManager); + await expect(accountManagerPromise).resolves.toBe(reloadedManager); + expect(accountReloadInFlight).toBeNull(); + }); +}); + From 678323ebaf6bce1e93780b8e0825d67f7d3afc9c Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 04:50:27 +0800 Subject: [PATCH 218/376] docs: dedupe archived release notes --- docs/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/README.md b/docs/README.md index 6aba682f..7c98b67e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,7 +28,6 @@ Public documentation for `codex-multi-auth`. | [upgrade.md](upgrade.md) | Migration from legacy package and path history | | [releases/v1.1.10.md](releases/v1.1.10.md) | Stable release notes | | [releases/v0.1.9.md](releases/v0.1.9.md) | Previous stable release notes | -| [releases/v0.1.7.md](releases/v0.1.7.md) | Earlier stable release notes | | [releases/v0.1.7.md](releases/v0.1.7.md) | Archived stable release notes | | [releases/v0.1.6.md](releases/v0.1.6.md) | Archived stable release notes | | [releases/v0.1.5.md](releases/v0.1.5.md) | Archived stable release notes | From 273889ff16284ce98826b5d41b00a8dd835a2687 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 04:55:13 +0800 Subject: [PATCH 219/376] fix: resolve init-config templates from dist --- lib/codex-manager/commands/init-config.ts | 7 ++-- test/init-config-command.test.ts | 43 +++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 test/init-config-command.test.ts diff --git a/lib/codex-manager/commands/init-config.ts b/lib/codex-manager/commands/init-config.ts index 222cce72..6a377473 100644 --- a/lib/codex-manager/commands/init-config.ts +++ b/lib/codex-manager/commands/init-config.ts @@ -1,5 +1,5 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; +import { dirname, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; const TEMPLATE_MAP = { @@ -75,7 +75,10 @@ export async function runInitConfigCommand( deps?.readTemplate ?? (async (template: TemplateName) => { const currentFile = fileURLToPath(import.meta.url); - const repoRoot = resolve(dirname(currentFile), "../../../"); + const currentDir = dirname(currentFile); + const repoRoot = currentDir.includes(`${sep}dist${sep}`) + ? resolve(currentDir, "../../../../") + : resolve(currentDir, "../../../"); const templatePath = resolve(repoRoot, "config", TEMPLATE_MAP[template]); return readFile(templatePath, "utf8"); }); diff --git a/test/init-config-command.test.ts b/test/init-config-command.test.ts new file mode 100644 index 00000000..6f64b7bf --- /dev/null +++ b/test/init-config-command.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("runInitConfigCommand", () => { + afterEach(() => { + vi.resetModules(); + vi.doUnmock("node:fs/promises"); + vi.doUnmock("node:url"); + }); + + it("resolves templates from the package root when running from dist output", async () => { + const readFileMock = vi.fn( + async () => '{\n "plugin": ["codex-multi-auth"]\n}\n', + ); + vi.doMock("node:fs/promises", () => ({ + mkdir: vi.fn(), + readFile: readFileMock, + writeFile: vi.fn(), + })); + vi.doMock("node:url", () => ({ + fileURLToPath: () => + "C:\\repo\\dist\\lib\\codex-manager\\commands\\init-config.js", + })); + + const { runInitConfigCommand } = await import( + "../lib/codex-manager/commands/init-config.js" + ); + const logInfo = vi.fn(); + const exitCode = await runInitConfigCommand(["modern"], { + logInfo, + logError: vi.fn(), + cwd: () => "C:\\repo", + }); + + expect(exitCode).toBe(0); + expect(readFileMock).toHaveBeenCalledWith( + "C:\\repo\\config\\codex-modern.json", + "utf8", + ); + expect(logInfo).toHaveBeenCalledWith( + expect.stringContaining('"plugin": ["codex-multi-auth"]'), + ); + }); +}); From cc5f7d9fb290d76aecdaa94e8b3ba4e2b8f07c8a Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 04:58:45 +0800 Subject: [PATCH 220/376] docs: document shared vendor shim hash --- vendor/provenance.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/vendor/provenance.json b/vendor/provenance.json index 0cbb8ca6..e14fc690 100644 --- a/vendor/provenance.json +++ b/vendor/provenance.json @@ -41,7 +41,8 @@ }, { "path": "vendor/codex-ai-sdk/dist/index.js", - "sha256": "aee9a2454b1778ff0af7c648a172074fe61446e21e76bac0e354ef1d12c0dc22" + "sha256": "aee9a2454b1778ff0af7c648a172074fe61446e21e76bac0e354ef1d12c0dc22", + "note": "Intentionally identical to vendor/codex-ai-plugin/dist/index.js; both shims export an empty ESM module." }, { "path": "vendor/codex-ai-sdk/dist/index.d.ts", From 41654f24aad6150346c5a2082e18986e3dd8607d Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 04:59:54 +0800 Subject: [PATCH 221/376] test: harden runtime flagged account flows --- lib/runtime/account-check.ts | 6 ++-- test/runtime-account-check.test.ts | 32 +++++++++++++++++++ test/runtime-verify-flagged.test.ts | 49 +++++++++++++++++++++++------ 3 files changed, 74 insertions(+), 13 deletions(-) diff --git a/lib/runtime/account-check.ts b/lib/runtime/account-check.ts index 6d1a36d0..a99c89f8 100644 --- a/lib/runtime/account-check.ts +++ b/lib/runtime/account-check.ts @@ -316,13 +316,13 @@ export async function runRuntimeAccountCheck( state.storageChanged = true; } + if (state.flaggedChanged) { + await deps.saveFlaggedAccounts(state.flaggedStorage); + } if (state.storageChanged) { await deps.saveAccounts(workingStorage); deps.invalidateAccountManagerCache(); } - if (state.flaggedChanged) { - await deps.saveFlaggedAccounts(state.flaggedStorage); - } deps.showLine(""); deps.showLine( diff --git a/test/runtime-account-check.test.ts b/test/runtime-account-check.test.ts index a2c2c0cc..4797b5b7 100644 --- a/test/runtime-account-check.test.ts +++ b/test/runtime-account-check.test.ts @@ -71,4 +71,36 @@ describe("runRuntimeAccountCheck", () => { expect(flaggedStorage.accounts[0]?.flaggedAt).toBe(1000); expect(now).toHaveBeenCalledTimes(1); }); + it("persists flagged storage before saving active accounts", async () => { + const calls: string[] = []; + await runRuntimeAccountCheck(true, { + hydrateEmails: async (storage) => storage, + loadAccounts: async () => ({ + version: 3, + accounts: [{ email: "one@example.com", refreshToken: "refresh-1", accessToken: undefined, addedAt: 1, lastUsed: 1 }], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }), + createEmptyStorage: () => ({ version: 3, accounts: [], activeIndex: 0, activeIndexByFamily: {} }), + loadFlaggedAccounts: async () => ({ version: 1, accounts: [] }), + createAccountCheckWorkingState: (flaggedStorage) => ({ flaggedStorage, removeFromActive: new Set(), storageChanged: false, flaggedChanged: false, ok: 0, errors: 0, disabled: 0 }), + lookupCodexCliTokensByEmail: async () => null, + extractAccountId: () => undefined, + shouldUpdateAccountIdFromToken: () => false, + sanitizeEmail: (email) => email, + extractAccountEmail: () => undefined, + queuedRefresh: async () => ({ type: "failed", reason: "invalid_grant", message: "refresh failed" }), + isRuntimeFlaggableFailure: () => true, + fetchCodexQuotaSnapshot: async () => { throw new Error("should not probe quota in deep mode"); }, + resolveRequestAccountId: () => undefined, + formatCodexQuotaLine: () => "quota", + clampRuntimeActiveIndices: vi.fn(), + MODEL_FAMILIES: ["codex"], + saveAccounts: vi.fn(async () => { calls.push("saveAccounts"); }), + invalidateAccountManagerCache: vi.fn(), + saveFlaggedAccounts: vi.fn(async () => { calls.push("saveFlaggedAccounts"); }), + showLine: vi.fn(), + }); + expect(calls).toEqual(["saveFlaggedAccounts", "saveAccounts"]); + }); }); diff --git a/test/runtime-verify-flagged.test.ts b/test/runtime-verify-flagged.test.ts index 9bb464a9..fb7b0e5f 100644 --- a/test/runtime-verify-flagged.test.ts +++ b/test/runtime-verify-flagged.test.ts @@ -2,6 +2,22 @@ import { describe, expect, it, vi } from "vitest"; import { verifyRuntimeFlaggedAccounts } from "../lib/runtime/verify-flagged.js"; describe("verifyRuntimeFlaggedAccounts", () => { + it("exits early when there are no flagged accounts", async () => { + const showLine = vi.fn(); + await verifyRuntimeFlaggedAccounts({ + loadFlaggedAccounts: async () => ({ version: 1, accounts: [] }), + lookupCodexCliTokensByEmail: async () => null, + queuedRefresh: async () => ({ type: "failed", reason: "invalid_grant" }), + resolveAccountSelection: () => ({}) as never, + persistAccounts: vi.fn(async () => {}), + invalidateAccountManagerCache: vi.fn(), + saveFlaggedAccounts: vi.fn(async () => {}), + logInfo: vi.fn(), + showLine, + }); + expect(showLine).toHaveBeenCalledWith("\nNo flagged accounts to verify.\n"); + }); + it("restores accounts from Codex CLI cache and preserves the remainder", async () => { const persistAccounts = vi.fn(async () => {}); const saveFlaggedAccounts = vi.fn(async () => {}); @@ -34,17 +50,32 @@ describe("verifyRuntimeFlaggedAccounts", () => { expect(showLine).toHaveBeenCalledWith(expect.stringContaining("ca***@***.com: RESTORED (Codex CLI cache)")); }); + it("restores accounts after a successful refresh when cache misses", async () => { + const persistAccounts = vi.fn(async () => {}); + const saveFlaggedAccounts = vi.fn(async () => {}); + const showLine = vi.fn(); + await verifyRuntimeFlaggedAccounts({ + loadFlaggedAccounts: async () => ({ version: 1, accounts: [{ email: "refresh@example.com", refreshToken: "refresh-token", addedAt: 1, lastUsed: 1 }] }), + lookupCodexCliTokensByEmail: async () => null, + queuedRefresh: async () => ({ type: "success", access: "new-access", refresh: "new-refresh", expires: Date.now() + 60_000 }), + resolveAccountSelection: (tokens) => ({ refreshToken: tokens.refresh, accessToken: tokens.access }) as never, + persistAccounts, + invalidateAccountManagerCache: vi.fn(), + saveFlaggedAccounts, + logInfo: vi.fn(), + showLine, + }); + expect(persistAccounts).toHaveBeenCalledWith([expect.objectContaining({ refreshToken: "new-refresh", accessToken: "new-access" })], false); + expect(saveFlaggedAccounts).toHaveBeenCalledWith({ version: 1, accounts: [] }); + expect(showLine).toHaveBeenCalledWith(expect.stringContaining("re***@***.com: RESTORED")); + }); + it("logs verification failures through logError and keeps the account flagged", async () => { const logError = vi.fn(); const saveFlaggedAccounts = vi.fn(async () => {}); await verifyRuntimeFlaggedAccounts({ - loadFlaggedAccounts: async () => ({ - version: 1, - accounts: [{ email: "broken@example.com", refreshToken: "broken-refresh", addedAt: 1, lastUsed: 1 }], - }), - lookupCodexCliTokensByEmail: async () => { - throw new Error("cache unavailable"); - }, + loadFlaggedAccounts: async () => ({ version: 1, accounts: [{ email: "broken@example.com", refreshToken: "broken-refresh", addedAt: 1, lastUsed: 1 }] }), + lookupCodexCliTokensByEmail: async () => { throw new Error("cache unavailable"); }, queuedRefresh: async () => ({ type: "failed", reason: "invalid_grant" }), resolveAccountSelection: () => ({}) as never, persistAccounts: vi.fn(async () => {}), @@ -54,9 +85,7 @@ describe("verifyRuntimeFlaggedAccounts", () => { logError, showLine: vi.fn(), }); - expect(logError).toHaveBeenCalledWith( - expect.stringContaining("Failed to verify flagged account br***@***.com: cache unavailable"), - ); + expect(logError).toHaveBeenCalledWith(expect.stringContaining("Failed to verify flagged account br***@***.com: cache unavailable")); expect(saveFlaggedAccounts).toHaveBeenCalledWith({ version: 1, accounts: [expect.objectContaining({ refreshToken: "broken-refresh", lastError: "cache unavailable" })], From 3cd01cba3a21df68b5fe51c0809df380fefd675e Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 04:59:54 +0800 Subject: [PATCH 222/376] fix: surface pack budget failure context --- scripts/check-pack-budget-lib.js | 9 +++++++-- test/check-pack-budget.test.ts | 7 ++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/scripts/check-pack-budget-lib.js b/scripts/check-pack-budget-lib.js index 59b76677..bf8a9c30 100644 --- a/scripts/check-pack-budget-lib.js +++ b/scripts/check-pack-budget-lib.js @@ -95,14 +95,19 @@ export async function runPackBudgetCheck(deps = {}) { })); } catch (error) { const message = error instanceof Error ? error.message : String(error); - throw new Error(`npm pack --dry-run --json failed via ${npmCommand}: ${message}`); + const stdoutText = typeof error === "object" && error && "stdout" in error ? String(error.stdout ?? "") : ""; + const stderrText = typeof error === "object" && error && "stderr" in error ? String(error.stderr ?? "") : ""; + throw new Error(`npm pack --dry-run --json failed via ${npmCommand}: ${message}${stdoutText ? ` +stdout: ${stdoutText.slice(0, 500)}` : ""}${stderrText ? ` +stderr: ${stderrText.slice(0, 500)}` : ""}`); } let summary; try { summary = validatePackMetadata(parsePackMetadata(stdout)); } catch (error) { const message = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to validate npm pack output: ${message}`); + throw new Error(`Failed to validate npm pack output: ${message} +stdout: ${stdout.slice(0, 500)}`); } log(summary); return summary; diff --git a/test/check-pack-budget.test.ts b/test/check-pack-budget.test.ts index 3d5e0987..e0cf101d 100644 --- a/test/check-pack-budget.test.ts +++ b/test/check-pack-budget.test.ts @@ -41,7 +41,7 @@ describe("parsePackMetadata", () => { }), log: vi.fn(), }), - ).rejects.toThrow(/npm pack --dry-run --json failed/); + ).rejects.toThrow(/failed via .*spawn failed/); }); it("wraps malformed pack output errors with validation context", async () => { @@ -50,7 +50,7 @@ describe("parsePackMetadata", () => { execAsync: vi.fn(async () => ({ stdout: "not-json" })), log: vi.fn(), }), - ).rejects.toThrow(/Failed to validate npm pack output/); + ).rejects.toThrow(/stdout: not-json/); }); }); @@ -91,7 +91,8 @@ describe("validatePackMetadata", () => { }), ).toThrow(/vendor\/codex-ai-sdk/); }); - it("rejects forbidden lib sources in the packed file list", () => { expect(() => + it("rejects forbidden lib sources in the packed file list", () => { + expect(() => validatePackMetadata({ packageSize: 123, paths: [ From 66d0515e3b1f96861171b831ce5154063bbec72c Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 05:01:17 +0800 Subject: [PATCH 223/376] test: cover runtime account selection --- test/runtime-account-selection.test.ts | 162 +++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 test/runtime-account-selection.test.ts diff --git a/test/runtime-account-selection.test.ts b/test/runtime-account-selection.test.ts new file mode 100644 index 00000000..66ffb3f9 --- /dev/null +++ b/test/runtime-account-selection.test.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../lib/accounts.js", () => ({ + getAccountIdCandidates: vi.fn(), + selectBestAccountCandidate: vi.fn(), +})); + +import { + getAccountIdCandidates, + selectBestAccountCandidate, +} from "../lib/accounts.js"; +import { + resolveAccountSelection, + type TokenSuccess, +} from "../lib/runtime/account-selection.js"; + +function createTokens(): TokenSuccess { + return { + type: "success", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + idToken: "id-token", + }; +} + +describe("resolveAccountSelection", () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.CODEX_AUTH_ACCOUNT_ID; + }); + + afterEach(() => { + delete process.env.CODEX_AUTH_ACCOUNT_ID; + }); + + it("uses CODEX_AUTH_ACCOUNT_ID override before token-derived candidates", () => { + process.env.CODEX_AUTH_ACCOUNT_ID = "override-account-12345"; + const tokens = createTokens(); + const logInfo = vi.fn(); + + const result = resolveAccountSelection(tokens, { logInfo }); + + expect(result).toEqual({ + ...tokens, + accountIdOverride: "override-account-12345", + accountIdSource: "manual", + accountLabel: "Override [id:-12345]", + }); + expect(logInfo).toHaveBeenCalledWith( + "Using account override from CODEX_AUTH_ACCOUNT_ID (id:-12345).", + ); + expect(getAccountIdCandidates).not.toHaveBeenCalled(); + expect(selectBestAccountCandidate).not.toHaveBeenCalled(); + }); + + it("returns the original tokens when no account candidates are available", () => { + vi.mocked(getAccountIdCandidates).mockReturnValueOnce([]); + const tokens = createTokens(); + + const result = resolveAccountSelection(tokens, { logInfo: vi.fn() }); + + expect(result).toBe(tokens); + expect(selectBestAccountCandidate).not.toHaveBeenCalled(); + }); + + it("attaches the only token-derived candidate directly", () => { + vi.mocked(getAccountIdCandidates).mockReturnValueOnce([ + { + accountId: "workspace-alpha", + source: "token", + label: "Workspace Alpha [id:-alpha]", + isDefault: true, + }, + ]); + const tokens = createTokens(); + + const result = resolveAccountSelection(tokens, { logInfo: vi.fn() }); + + expect(result).toEqual({ + ...tokens, + accountIdOverride: "workspace-alpha", + accountIdSource: "token", + accountLabel: "Workspace Alpha [id:-alpha]", + workspaces: [ + { + id: "workspace-alpha", + name: "Workspace Alpha [id:-alpha]", + enabled: true, + isDefault: true, + }, + ], + }); + expect(selectBestAccountCandidate).not.toHaveBeenCalled(); + }); + + it("uses the best account candidate when multiple workspaces are available", () => { + const candidates = [ + { + accountId: "workspace-alpha", + source: "token" as const, + label: "Workspace Alpha [id:-alpha]", + }, + { + accountId: "workspace-beta", + source: "org" as const, + label: "Workspace Beta [id:-beta]", + isDefault: true, + }, + ]; + vi.mocked(getAccountIdCandidates).mockReturnValueOnce(candidates); + vi.mocked(selectBestAccountCandidate).mockReturnValueOnce(candidates[1]); + const tokens = createTokens(); + + const result = resolveAccountSelection(tokens, { logInfo: vi.fn() }); + + expect(selectBestAccountCandidate).toHaveBeenCalledWith(candidates); + expect(result).toEqual({ + ...tokens, + accountIdOverride: "workspace-beta", + accountIdSource: "org", + accountLabel: "Workspace Beta [id:-beta]", + workspaces: [ + { + id: "workspace-alpha", + name: "Workspace Alpha [id:-alpha]", + enabled: true, + isDefault: undefined, + }, + { + id: "workspace-beta", + name: "Workspace Beta [id:-beta]", + enabled: true, + isDefault: true, + }, + ], + }); + }); + + it("falls back to the original tokens when no best candidate is selected", () => { + const candidates = [ + { + accountId: "workspace-alpha", + source: "token" as const, + label: "Workspace Alpha [id:-alpha]", + }, + { + accountId: "workspace-beta", + source: "org" as const, + label: "Workspace Beta [id:-beta]", + }, + ]; + vi.mocked(getAccountIdCandidates).mockReturnValueOnce(candidates); + vi.mocked(selectBestAccountCandidate).mockReturnValueOnce(null); + const tokens = createTokens(); + + const result = resolveAccountSelection(tokens, { logInfo: vi.fn() }); + + expect(selectBestAccountCandidate).toHaveBeenCalledWith(candidates); + expect(result).toBe(tokens); + }); +}); From daaf79819a5a50d62e15def40006f87f95fc96f2 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 05:02:24 +0800 Subject: [PATCH 224/376] test: expand flagged verification coverage --- test/runtime-verify-flagged.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/runtime-verify-flagged.test.ts b/test/runtime-verify-flagged.test.ts index fb7b0e5f..a7507be4 100644 --- a/test/runtime-verify-flagged.test.ts +++ b/test/runtime-verify-flagged.test.ts @@ -17,7 +17,6 @@ describe("verifyRuntimeFlaggedAccounts", () => { }); expect(showLine).toHaveBeenCalledWith("\nNo flagged accounts to verify.\n"); }); - it("restores accounts from Codex CLI cache and preserves the remainder", async () => { const persistAccounts = vi.fn(async () => {}); const saveFlaggedAccounts = vi.fn(async () => {}); From 8c3168959954302b61a1c70f65279569c12c5405 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 05:02:24 +0800 Subject: [PATCH 225/376] fix: tighten runtime account helper details --- lib/config.ts | 2 + lib/runtime/account-check.ts | 6 +-- lib/runtime/session-recovery.ts | 3 +- test/runtime-account-check.test.ts | 64 ++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 4 deletions(-) diff --git a/lib/config.ts b/lib/config.ts index ce83c708..709f4d16 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -1216,6 +1216,7 @@ type ConfigExplainMeta = { sourceKeys?: (keyof PluginConfig)[]; }; +/** CLI-only helper; not concurrency-safe because it temporarily mutates process.env. */ function withExplainEnvUnset(envNames: string[], run: () => T): T { const previous = new Map(); for (const name of envNames) { @@ -1251,6 +1252,7 @@ function resolveConfigExplainSource( return "env"; } const defaultResolvedValue = withExplainEnvUnset(entry.envNames, () => + // empty config to trigger default-resolution path in getters entry.getValue({} as PluginConfig), ); const storedKeys = entry.sourceKeys ?? [entry.key]; diff --git a/lib/runtime/account-check.ts b/lib/runtime/account-check.ts index 6d1a36d0..a99c89f8 100644 --- a/lib/runtime/account-check.ts +++ b/lib/runtime/account-check.ts @@ -316,13 +316,13 @@ export async function runRuntimeAccountCheck( state.storageChanged = true; } + if (state.flaggedChanged) { + await deps.saveFlaggedAccounts(state.flaggedStorage); + } if (state.storageChanged) { await deps.saveAccounts(workingStorage); deps.invalidateAccountManagerCache(); } - if (state.flaggedChanged) { - await deps.saveFlaggedAccounts(state.flaggedStorage); - } deps.showLine(""); deps.showLine( diff --git a/lib/runtime/session-recovery.ts b/lib/runtime/session-recovery.ts index a6e709ae..52f07a76 100644 --- a/lib/runtime/session-recovery.ts +++ b/lib/runtime/session-recovery.ts @@ -1,8 +1,9 @@ +import type { PluginInput } from "@codex-ai/plugin"; import { createSessionRecoveryHook } from "../recovery.js"; export function createRuntimeSessionRecoveryHook(deps: { enabled: boolean; - client: unknown; + client: PluginInput["client"]; directory: string; autoResume: boolean; }) { diff --git a/test/runtime-account-check.test.ts b/test/runtime-account-check.test.ts index a2c2c0cc..ee3d9f68 100644 --- a/test/runtime-account-check.test.ts +++ b/test/runtime-account-check.test.ts @@ -71,4 +71,68 @@ describe("runRuntimeAccountCheck", () => { expect(flaggedStorage.accounts[0]?.flaggedAt).toBe(1000); expect(now).toHaveBeenCalledTimes(1); }); + it("masks emails in output lines", async () => { + const showLine = vi.fn(); + await runRuntimeAccountCheck(false, { + hydrateEmails: async (storage) => storage, + loadAccounts: async () => ({ + version: 3, + accounts: [{ email: "visible@example.com", refreshToken: "r1", accessToken: "a1", addedAt: 1, lastUsed: 1, expiresAt: Date.now() + 60_000 }], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }), + createEmptyStorage: () => ({ version: 3, accounts: [], activeIndex: 0, activeIndexByFamily: {} }), + loadFlaggedAccounts: async () => ({ version: 1, accounts: [] }), + createAccountCheckWorkingState: (flaggedStorage) => ({ flaggedStorage, removeFromActive: new Set(), storageChanged: false, flaggedChanged: false, ok: 0, errors: 0, disabled: 0 }), + lookupCodexCliTokensByEmail: async () => null, + extractAccountId: () => undefined, + shouldUpdateAccountIdFromToken: () => false, + sanitizeEmail: (email) => email, + extractAccountEmail: () => undefined, + queuedRefresh: async () => ({ type: "failed", reason: "invalid_grant" }), + isRuntimeFlaggableFailure: () => false, + fetchCodexQuotaSnapshot: async () => ({ remaining5h: 1, remaining7d: 2 } as never), + resolveRequestAccountId: () => "acct", + formatCodexQuotaLine: () => "quota ok", + clampRuntimeActiveIndices: vi.fn(), + MODEL_FAMILIES: ["codex"], + saveAccounts: vi.fn(async () => {}), + invalidateAccountManagerCache: vi.fn(), + saveFlaggedAccounts: vi.fn(async () => {}), + showLine, + }); + expect(showLine).toHaveBeenCalledWith(expect.stringContaining("vi***@***.com: quota ok")); + }); + it("persists flagged storage before saving active accounts", async () => { + const calls: string[] = []; + await runRuntimeAccountCheck(true, { + hydrateEmails: async (storage) => storage, + loadAccounts: async () => ({ + version: 3, + accounts: [{ email: "one@example.com", refreshToken: "refresh-1", accessToken: undefined, addedAt: 1, lastUsed: 1 }], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }), + createEmptyStorage: () => ({ version: 3, accounts: [], activeIndex: 0, activeIndexByFamily: {} }), + loadFlaggedAccounts: async () => ({ version: 1, accounts: [] }), + createAccountCheckWorkingState: (flaggedStorage) => ({ flaggedStorage, removeFromActive: new Set(), storageChanged: false, flaggedChanged: false, ok: 0, errors: 0, disabled: 0 }), + lookupCodexCliTokensByEmail: async () => null, + extractAccountId: () => undefined, + shouldUpdateAccountIdFromToken: () => false, + sanitizeEmail: (email) => email, + extractAccountEmail: () => undefined, + queuedRefresh: async () => ({ type: "failed", reason: "invalid_grant", message: "refresh failed" }), + isRuntimeFlaggableFailure: () => true, + fetchCodexQuotaSnapshot: async () => { throw new Error("should not probe quota in deep mode"); }, + resolveRequestAccountId: () => undefined, + formatCodexQuotaLine: () => "quota", + clampRuntimeActiveIndices: vi.fn(), + MODEL_FAMILIES: ["codex"], + saveAccounts: vi.fn(async () => { calls.push("saveAccounts"); }), + invalidateAccountManagerCache: vi.fn(), + saveFlaggedAccounts: vi.fn(async () => { calls.push("saveFlaggedAccounts"); }), + showLine: vi.fn(), + }); + expect(calls).toEqual(["saveFlaggedAccounts", "saveAccounts"]); + }); }); From 8ca16d0051824fc4612a5da9966bb393ffc91f00 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 05:04:37 +0800 Subject: [PATCH 226/376] fix: prefer cached refresh token for account check --- lib/runtime/account-check.ts | 11 ++++- test/runtime-account-check.test.ts | 66 ++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/lib/runtime/account-check.ts b/lib/runtime/account-check.ts index a99c89f8..dfc2bc8d 100644 --- a/lib/runtime/account-check.ts +++ b/lib/runtime/account-check.ts @@ -134,7 +134,16 @@ export async function runRuntimeAccountCheck( } if (!accessToken) { - const cached = await deps.lookupCodexCliTokensByEmail(account.email); + const cached = await deps + .lookupCodexCliTokensByEmail(account.email) + .catch(() => null); + if ( + cached?.refreshToken && + cached.refreshToken !== account.refreshToken + ) { + account.refreshToken = cached.refreshToken; + state.storageChanged = true; + } if ( cached && (typeof cached.expiresAt !== "number" || diff --git a/test/runtime-account-check.test.ts b/test/runtime-account-check.test.ts index 4797b5b7..79915650 100644 --- a/test/runtime-account-check.test.ts +++ b/test/runtime-account-check.test.ts @@ -103,4 +103,70 @@ describe("runRuntimeAccountCheck", () => { }); expect(calls).toEqual(["saveFlaggedAccounts", "saveAccounts"]); }); + it("promotes a newer cached refresh token even when cached access is expired", async () => { + const saveAccounts = vi.fn(async () => {}); + await runRuntimeAccountCheck(false, { + hydrateEmails: async (storage) => storage, + loadAccounts: async () => ({ + version: 3, + accounts: [{ email: "one@example.com", refreshToken: "stale-refresh", accessToken: undefined, addedAt: 1, lastUsed: 1 }], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }), + createEmptyStorage: () => ({ version: 3, accounts: [], activeIndex: 0, activeIndexByFamily: {} }), + loadFlaggedAccounts: async () => ({ version: 1, accounts: [] }), + createAccountCheckWorkingState: (flaggedStorage) => ({ flaggedStorage, removeFromActive: new Set(), storageChanged: false, flaggedChanged: false, ok: 0, errors: 0, disabled: 0 }), + lookupCodexCliTokensByEmail: async () => ({ accessToken: "expired-access", refreshToken: "fresh-refresh", expiresAt: Date.now() - 1 }), + extractAccountId: () => undefined, + shouldUpdateAccountIdFromToken: () => false, + sanitizeEmail: (email) => email, + extractAccountEmail: () => undefined, + queuedRefresh: async (refreshToken) => ({ type: "success", access: "new-access", refresh: refreshToken, expires: Date.now() + 60_000 }), + isRuntimeFlaggableFailure: () => false, + fetchCodexQuotaSnapshot: async () => ({ remaining5h: 1, remaining7d: 2 } as never), + resolveRequestAccountId: () => "acct", + formatCodexQuotaLine: () => "quota ok", + clampRuntimeActiveIndices: vi.fn(), + MODEL_FAMILIES: ["codex"], + saveAccounts, + invalidateAccountManagerCache: vi.fn(), + saveFlaggedAccounts: vi.fn(async () => {}), + showLine: vi.fn(), + }); + const saved = saveAccounts.mock.calls[0]?.[0]; + expect(saved.accounts[0]?.refreshToken).toBe("fresh-refresh"); + }); + + it("treats cache lookup failures as a cache miss and still refreshes", async () => { + const saveAccounts = vi.fn(async () => {}); + await runRuntimeAccountCheck(false, { + hydrateEmails: async (storage) => storage, + loadAccounts: async () => ({ + version: 3, + accounts: [{ email: "one@example.com", refreshToken: "refresh-1", accessToken: undefined, addedAt: 1, lastUsed: 1 }], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }), + createEmptyStorage: () => ({ version: 3, accounts: [], activeIndex: 0, activeIndexByFamily: {} }), + loadFlaggedAccounts: async () => ({ version: 1, accounts: [] }), + createAccountCheckWorkingState: (flaggedStorage) => ({ flaggedStorage, removeFromActive: new Set(), storageChanged: false, flaggedChanged: false, ok: 0, errors: 0, disabled: 0 }), + lookupCodexCliTokensByEmail: async () => { throw new Error("busy"); }, + extractAccountId: () => undefined, + shouldUpdateAccountIdFromToken: () => false, + sanitizeEmail: (email) => email, + extractAccountEmail: () => undefined, + queuedRefresh: async () => ({ type: "success", access: "new-access", refresh: "refresh-1", expires: Date.now() + 60_000 }), + isRuntimeFlaggableFailure: () => false, + fetchCodexQuotaSnapshot: async () => ({ remaining5h: 1, remaining7d: 2 } as never), + resolveRequestAccountId: () => "acct", + formatCodexQuotaLine: () => "quota ok", + clampRuntimeActiveIndices: vi.fn(), + MODEL_FAMILIES: ["codex"], + saveAccounts, + invalidateAccountManagerCache: vi.fn(), + saveFlaggedAccounts: vi.fn(async () => {}), + showLine: vi.fn(), + }); + expect(saveAccounts).toHaveBeenCalledTimes(1); + }); }); From 271b5f0c819c862fae31fdff22edd1960d3155ea Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 05:06:13 +0800 Subject: [PATCH 227/376] fix: guard oauth browser launch on server readiness --- index.ts | 2 +- lib/runtime/oauth-browser-flow.ts | 4 ++-- test/index.test.ts | 21 +++++++++++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/index.ts b/index.ts index 6fd871a0..0be9a7b8 100644 --- a/index.ts +++ b/index.ts @@ -284,7 +284,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { manualModeLabel: AUTH_LABELS.OAUTH_MANUAL, logInfo, logDebug: (message) => logDebug(`[${PLUGIN_NAME}] ${message}`), - logWarn: (message) => logWarn(`[${PLUGIN_NAME}] ${message}`), + logWarn: (message) => logWarn(`\n[${PLUGIN_NAME}] ${message}`), }); const persistAccountPool = async ( diff --git a/lib/runtime/oauth-browser-flow.ts b/lib/runtime/oauth-browser-flow.ts index 70592993..a602047d 100644 --- a/lib/runtime/oauth-browser-flow.ts +++ b/lib/runtime/oauth-browser-flow.ts @@ -30,15 +30,15 @@ export async function runOAuthBrowserFlow(deps: { ); serverInfo = null; } - openBrowserUrl(url); if (!serverInfo || !serverInfo.ready) { serverInfo?.close(); deps.logWarn( - `\nOAuth callback server failed to start. Please retry with "${deps.manualModeLabel}".\n`, + `OAuth callback server failed to start. Please retry with "${deps.manualModeLabel}".\n`, ); return { type: "failed" as const }; } + openBrowserUrl(url); const result = await serverInfo.waitForCode(state); serverInfo.close(); diff --git a/test/index.test.ts b/test/index.test.ts index fa56f49d..216368e3 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -672,6 +672,27 @@ describe("OpenAIOAuthPlugin", () => { expect(openBrowserUrlMock).not.toHaveBeenCalled(); }); + it("does not open the browser when the oauth callback server fails to start", async () => { + const browserModule = await import("../lib/auth/browser.js"); + const loggerModule = await import("../lib/logger.js"); + const serverModule = await import("../lib/auth/server.js"); + const openBrowserUrlMock = vi.mocked(browserModule.openBrowserUrl); + vi.mocked(serverModule.startLocalOAuthServer).mockRejectedValueOnce( + new Error("EADDRINUSE"), + ); + + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: () => Promise; + }; + await autoMethod.authorize(); + const warnCall = vi.mocked(loggerModule.logWarn).mock.calls.at(-1)?.[0]; + + expect(openBrowserUrlMock).not.toHaveBeenCalled(); + expect(warnCall).toBe( + '\n[codex-multi-auth] OAuth callback server failed to start. Please retry with "ChatGPT Plus/Pro MULTI (Manual URL Paste)".\n', + ); + }); + it("rejects manual OAuth callbacks with mismatched state", async () => { const authModule = await import("../lib/auth/auth.js"); const manualMethod = plugin.auth.methods[1] as unknown as { From 5aa8808fe6bc5ad6d169416333775c2d73693432 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 05:06:14 +0800 Subject: [PATCH 228/376] test: cover account-check save ordering --- test/runtime-account-check.test.ts | 41 ++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/test/runtime-account-check.test.ts b/test/runtime-account-check.test.ts index 79915650..f62f4b55 100644 --- a/test/runtime-account-check.test.ts +++ b/test/runtime-account-check.test.ts @@ -169,4 +169,45 @@ describe("runRuntimeAccountCheck", () => { }); expect(saveAccounts).toHaveBeenCalledTimes(1); }); + it("keeps flagged accounts durable when saving active accounts fails", async () => { + const saveFlaggedAccounts = vi.fn(async () => {}); + const saveAccounts = vi.fn(async () => { + const error = new Error("busy") as Error & { code?: string }; + error.code = "EBUSY"; + throw error; + }); + await expect( + runRuntimeAccountCheck(true, { + hydrateEmails: async (storage) => storage, + loadAccounts: async () => ({ + version: 3, + accounts: [{ email: "one@example.com", refreshToken: "refresh-1", accessToken: undefined, addedAt: 1, lastUsed: 1 }], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }), + createEmptyStorage: () => ({ version: 3, accounts: [], activeIndex: 0, activeIndexByFamily: {} }), + loadFlaggedAccounts: async () => ({ version: 1, accounts: [] }), + createAccountCheckWorkingState: (flaggedStorage) => ({ flaggedStorage, removeFromActive: new Set(), storageChanged: false, flaggedChanged: false, ok: 0, errors: 0, disabled: 0 }), + lookupCodexCliTokensByEmail: async () => null, + extractAccountId: () => undefined, + shouldUpdateAccountIdFromToken: () => false, + sanitizeEmail: (email) => email, + extractAccountEmail: () => undefined, + queuedRefresh: async () => ({ type: "failed", reason: "invalid_grant", message: "refresh failed" }), + isRuntimeFlaggableFailure: () => true, + fetchCodexQuotaSnapshot: async () => { throw new Error("should not probe quota in deep mode"); }, + resolveRequestAccountId: () => undefined, + formatCodexQuotaLine: () => "quota", + clampRuntimeActiveIndices: vi.fn(), + MODEL_FAMILIES: ["codex"], + saveAccounts, + invalidateAccountManagerCache: vi.fn(), + saveFlaggedAccounts, + showLine: vi.fn(), + }), + ).rejects.toThrow("busy"); + expect(saveFlaggedAccounts).toHaveBeenCalledTimes(1); + expect(saveAccounts).toHaveBeenCalledTimes(1); + expect(saveFlaggedAccounts.mock.invocationCallOrder[0]).toBeLessThan(saveAccounts.mock.invocationCallOrder[0]); + }); }); From bc5fcc4b5858fd69fb4658f9cafec58182239b99 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 05:06:15 +0800 Subject: [PATCH 229/376] test: cover account-check save ordering --- test/runtime-account-check.test.ts | 41 ++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/test/runtime-account-check.test.ts b/test/runtime-account-check.test.ts index ee3d9f68..accd387e 100644 --- a/test/runtime-account-check.test.ts +++ b/test/runtime-account-check.test.ts @@ -135,4 +135,45 @@ describe("runRuntimeAccountCheck", () => { }); expect(calls).toEqual(["saveFlaggedAccounts", "saveAccounts"]); }); + it("keeps flagged accounts durable when saving active accounts fails", async () => { + const saveFlaggedAccounts = vi.fn(async () => {}); + const saveAccounts = vi.fn(async () => { + const error = new Error("busy") as Error & { code?: string }; + error.code = "EBUSY"; + throw error; + }); + await expect( + runRuntimeAccountCheck(true, { + hydrateEmails: async (storage) => storage, + loadAccounts: async () => ({ + version: 3, + accounts: [{ email: "one@example.com", refreshToken: "refresh-1", accessToken: undefined, addedAt: 1, lastUsed: 1 }], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }), + createEmptyStorage: () => ({ version: 3, accounts: [], activeIndex: 0, activeIndexByFamily: {} }), + loadFlaggedAccounts: async () => ({ version: 1, accounts: [] }), + createAccountCheckWorkingState: (flaggedStorage) => ({ flaggedStorage, removeFromActive: new Set(), storageChanged: false, flaggedChanged: false, ok: 0, errors: 0, disabled: 0 }), + lookupCodexCliTokensByEmail: async () => null, + extractAccountId: () => undefined, + shouldUpdateAccountIdFromToken: () => false, + sanitizeEmail: (email) => email, + extractAccountEmail: () => undefined, + queuedRefresh: async () => ({ type: "failed", reason: "invalid_grant", message: "refresh failed" }), + isRuntimeFlaggableFailure: () => true, + fetchCodexQuotaSnapshot: async () => { throw new Error("should not probe quota in deep mode"); }, + resolveRequestAccountId: () => undefined, + formatCodexQuotaLine: () => "quota", + clampRuntimeActiveIndices: vi.fn(), + MODEL_FAMILIES: ["codex"], + saveAccounts, + invalidateAccountManagerCache: vi.fn(), + saveFlaggedAccounts, + showLine: vi.fn(), + }), + ).rejects.toThrow("busy"); + expect(saveFlaggedAccounts).toHaveBeenCalledTimes(1); + expect(saveAccounts).toHaveBeenCalledTimes(1); + expect(saveFlaggedAccounts.mock.invocationCallOrder[0]).toBeLessThan(saveAccounts.mock.invocationCallOrder[0]); + }); }); From e50d8b39691ff6e2221b45c16eb3eced195fcf63 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 05:08:33 +0800 Subject: [PATCH 230/376] test: cover runtime account pool persistence --- test/account-pool.test.ts | 320 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 test/account-pool.test.ts diff --git a/test/account-pool.test.ts b/test/account-pool.test.ts new file mode 100644 index 00000000..2e660552 --- /dev/null +++ b/test/account-pool.test.ts @@ -0,0 +1,320 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { ModelFamily } from "../lib/prompts/codex.js"; +import type { AccountStorageV3 } from "../lib/storage.js"; +import type { TokenSuccessWithAccount } from "../lib/runtime/account-selection.js"; +import { + persistAccountPool, + type PersistAccountPoolDeps, +} from "../lib/runtime/account-pool.js"; + +const MODEL_FAMILIES = ["codex", "gpt-5.1"] as const satisfies readonly ModelFamily[]; + +function createResult( + overrides: Partial = {}, +): TokenSuccessWithAccount { + return { + type: "success", + access: "access-token", + refresh: "refresh-token", + expires: 1_800_000_000_000, + idToken: "User@Example.com", + ...overrides, + }; +} + +function createDeps(options?: { + loadedStorage?: AccountStorageV3 | null; + now?: number; +}) { + let persistedStorage: AccountStorageV3 | null = null; + const persist = vi.fn(async (nextStorage: AccountStorageV3) => { + persistedStorage = nextStorage; + }); + const withAccountStorageTransaction = vi.fn< + PersistAccountPoolDeps["withAccountStorageTransaction"] + >(async (callback) => { + await callback(options?.loadedStorage ?? null, persist); + }); + const deps: PersistAccountPoolDeps = { + withAccountStorageTransaction, + extractAccountId: (accessToken) => + accessToken ? `derived:${accessToken}` : undefined, + extractAccountEmail: (_accessToken, idToken) => + typeof idToken === "string" ? idToken : undefined, + sanitizeEmail: (email) => email?.trim().toLowerCase(), + findMatchingAccountIndex: (accounts, target) => { + const matchIndex = accounts.findIndex((account) => { + if ( + target.refreshToken && + account.refreshToken === target.refreshToken + ) { + return true; + } + if ( + target.accountId && + target.email && + account.accountId === target.accountId && + account.email === target.email + ) { + return true; + } + return Boolean( + target.accountId && + !target.email && + account.accountId === target.accountId, + ); + }); + return matchIndex >= 0 ? matchIndex : undefined; + }, + MODEL_FAMILIES, + getNow: () => options?.now ?? 1_700_000_000_000, + }; + + return { + deps, + persist, + withAccountStorageTransaction, + getPersistedStorage: () => persistedStorage, + }; +} + +describe("persistAccountPool", () => { + it("adds a new account and uses getNow for addedAt and lastUsed", async () => { + const { deps, getPersistedStorage } = createDeps({ now: 123_456 }); + + await persistAccountPool( + [ + createResult({ + accountIdOverride: "workspace-b", + accountIdSource: "manual", + accountLabel: "Workspace B [id:ace-b]", + workspaces: [ + { id: "workspace-a", name: "Workspace A", enabled: true }, + { + id: "workspace-b", + name: "Workspace B", + enabled: true, + isDefault: true, + }, + ], + }), + ], + false, + deps, + ); + + expect(getPersistedStorage()).toEqual({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { + codex: 0, + "gpt-5.1": 0, + }, + accounts: [ + expect.objectContaining({ + accountId: "workspace-b", + accountIdSource: "manual", + accountLabel: "Workspace B [id:ace-b]", + email: "user@example.com", + refreshToken: "refresh-token", + accessToken: "access-token", + expiresAt: 1_800_000_000_000, + addedAt: 123_456, + lastUsed: 123_456, + currentWorkspaceIndex: 1, + workspaces: [ + { id: "workspace-a", name: "Workspace A", enabled: true }, + { + id: "workspace-b", + name: "Workspace B", + enabled: true, + isDefault: true, + }, + ], + }), + ], + }); + }); + + it("updates an existing account and preserves the active workspace id", async () => { + const loadedStorage: AccountStorageV3 = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0, "gpt-5.1": 0 }, + accounts: [ + { + accountId: "shared-workspace", + accountIdSource: "manual", + accountLabel: "Workspace B [id:ace-b]", + email: "user@example.com", + refreshToken: "refresh-token", + accessToken: "old-access", + expiresAt: 10, + addedAt: 111, + lastUsed: 222, + workspaces: [ + { id: "workspace-a", name: "Workspace A", enabled: true }, + { + id: "workspace-b", + name: "Workspace B", + enabled: false, + disabledAt: 999, + isDefault: true, + }, + ], + currentWorkspaceIndex: 1, + }, + ], + }; + const { deps, getPersistedStorage } = createDeps({ + loadedStorage, + now: 456_789, + }); + + await persistAccountPool( + [ + createResult({ + accountIdOverride: "shared-workspace", + accountIdSource: "manual", + accountLabel: "Workspace B Renamed [id:ace-b]", + workspaces: [ + { + id: "workspace-b", + name: "Workspace B Renamed", + enabled: true, + isDefault: true, + }, + { id: "workspace-a", name: "Workspace A", enabled: true }, + ], + }), + ], + false, + deps, + ); + + expect(getPersistedStorage()?.accounts[0]).toEqual( + expect.objectContaining({ + accountId: "shared-workspace", + accountLabel: "Workspace B Renamed [id:ace-b]", + email: "user@example.com", + refreshToken: "refresh-token", + accessToken: "access-token", + addedAt: 111, + lastUsed: 456_789, + currentWorkspaceIndex: 0, + workspaces: [ + { + id: "workspace-b", + name: "Workspace B Renamed", + enabled: false, + disabledAt: 999, + isDefault: true, + }, + { id: "workspace-a", name: "Workspace A", enabled: true }, + ], + }), + ); + }); + + it("falls back to the default workspace when the current workspace disappears", async () => { + const loadedStorage: AccountStorageV3 = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0, "gpt-5.1": 0 }, + accounts: [ + { + accountId: "shared-workspace", + accountIdSource: "manual", + accountLabel: "Workspace B [id:ace-b]", + email: "user@example.com", + refreshToken: "refresh-token", + accessToken: "old-access", + expiresAt: 10, + addedAt: 111, + lastUsed: 222, + workspaces: [ + { id: "workspace-a", name: "Workspace A", enabled: true }, + { id: "workspace-b", name: "Workspace B", enabled: true }, + ], + currentWorkspaceIndex: 1, + }, + ], + }; + const { deps, getPersistedStorage } = createDeps({ loadedStorage }); + + await persistAccountPool( + [ + createResult({ + accountIdOverride: "shared-workspace", + workspaces: [ + { + id: "workspace-a", + name: "Workspace A", + enabled: true, + isDefault: true, + }, + { id: "workspace-c", name: "Workspace C", enabled: true }, + ], + }), + ], + false, + deps, + ); + + expect(getPersistedStorage()?.accounts[0]?.currentWorkspaceIndex).toBe(0); + }); + + it("resets active indices when replaceAll is requested", async () => { + const loadedStorage: AccountStorageV3 = { + version: 3, + activeIndex: 9, + activeIndexByFamily: { codex: 8, "gpt-5.1": 7 }, + accounts: [ + { + accountId: "old-account", + refreshToken: "old-refresh", + addedAt: 1, + lastUsed: 1, + }, + ], + }; + const { deps, getPersistedStorage } = createDeps({ loadedStorage }); + + await persistAccountPool( + [ + createResult({ + accountIdOverride: "new-account", + refresh: "refresh-a", + }), + createResult({ + accountIdOverride: "other-account", + refresh: "refresh-b", + access: "access-b", + }), + ], + true, + deps, + ); + + expect(getPersistedStorage()).toEqual( + expect.objectContaining({ + activeIndex: 0, + activeIndexByFamily: { + codex: 0, + "gpt-5.1": 0, + }, + }), + ); + expect(getPersistedStorage()?.accounts).toHaveLength(2); + }); + + it("short-circuits before opening a storage transaction when results are empty", async () => { + const { deps, withAccountStorageTransaction, persist } = createDeps(); + + await persistAccountPool([], false, deps); + + expect(withAccountStorageTransaction).not.toHaveBeenCalled(); + expect(persist).not.toHaveBeenCalled(); + }); +}); From 24b9108ac9a5b97efaa51dafc9c920ad7f9750ab Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 05:09:44 +0800 Subject: [PATCH 231/376] fix: harden runtime reload and flagged writes --- lib/runtime/auth-facade.ts | 11 ++++++--- lib/runtime/verify-flagged.ts | 10 ++++---- test/runtime-auth-facade.test.ts | 14 +++++++++++ test/runtime-verify-flagged.test.ts | 38 +++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 8 deletions(-) diff --git a/lib/runtime/auth-facade.ts b/lib/runtime/auth-facade.ts index 1b9a60fd..5a45cc31 100644 --- a/lib/runtime/auth-facade.ts +++ b/lib/runtime/auth-facade.ts @@ -66,13 +66,18 @@ export function createAccountManagerReloader(deps: { setAccountManagerPromise: (value: Promise | null) => void; setReloadInFlight: (value: Promise | null) => void; }) { - return async (authFallback?: OAuthAuthDetails): Promise => - deps.reloadRuntimeAccountManager({ - currentReloadInFlight: deps.getReloadInFlight(), + return async (authFallback?: OAuthAuthDetails): Promise => { + const inFlight = deps.getReloadInFlight(); + if (inFlight) { + return inFlight; + } + return deps.reloadRuntimeAccountManager({ + currentReloadInFlight: inFlight, loadFromDisk: deps.loadFromDisk, setCachedAccountManager: deps.setCachedAccountManager, setAccountManagerPromise: deps.setAccountManagerPromise, setReloadInFlight: deps.setReloadInFlight, authFallback, }); + }; } diff --git a/lib/runtime/verify-flagged.ts b/lib/runtime/verify-flagged.ts index 6d90a483..418e4a1e 100644 --- a/lib/runtime/verify-flagged.ts +++ b/lib/runtime/verify-flagged.ts @@ -139,16 +139,16 @@ export async function verifyRuntimeFlaggedAccounts(deps: { } } - if (state.restored.length > 0) { - await deps.persistAccounts(state.restored, false); - deps.invalidateAccountManagerCache(); - } - await deps.saveFlaggedAccounts({ version: 1, accounts: state.remaining, }); + if (state.restored.length > 0) { + await deps.persistAccounts(state.restored, false); + deps.invalidateAccountManagerCache(); + } + deps.showLine(""); deps.showLine( `Results: ${state.restored.length} restored, ${state.remaining.length} still flagged`, diff --git a/test/runtime-auth-facade.test.ts b/test/runtime-auth-facade.test.ts index 8eca4d76..f594018c 100644 --- a/test/runtime-auth-facade.test.ts +++ b/test/runtime-auth-facade.test.ts @@ -27,6 +27,20 @@ describe("runRuntimeOAuthFlow", () => { expect(logDebug).toHaveBeenCalledWith("[codex-multi-auth] debug message"); expect(logWarn).toHaveBeenCalledWith("[codex-multi-auth] warn message"); }); + it("returns existing reload promise when one is in flight", async () => { + const reloadRuntimeAccountManager = vi.fn(async () => "manager"); + const inFlight = Promise.resolve("existing-manager"); + const reloader = createAccountManagerReloader({ + reloadRuntimeAccountManager, + getReloadInFlight: () => inFlight as Promise, + loadFromDisk: vi.fn(async () => "manager"), + setCachedAccountManager: vi.fn(), + setAccountManagerPromise: vi.fn(), + setReloadInFlight: vi.fn(), + }); + await expect(reloader()).resolves.toBe("existing-manager"); + expect(reloadRuntimeAccountManager).not.toHaveBeenCalled(); + }); }); describe("createPersistAccounts", () => { diff --git a/test/runtime-verify-flagged.test.ts b/test/runtime-verify-flagged.test.ts index a7507be4..63d5d680 100644 --- a/test/runtime-verify-flagged.test.ts +++ b/test/runtime-verify-flagged.test.ts @@ -90,4 +90,42 @@ describe("verifyRuntimeFlaggedAccounts", () => { accounts: [expect.objectContaining({ refreshToken: "broken-refresh", lastError: "cache unavailable" })], }); }); + it("writes flagged state before restored accounts", async () => { + const calls: string[] = []; + await verifyRuntimeFlaggedAccounts({ + loadFlaggedAccounts: async () => ({ version: 1, accounts: [{ email: "refresh@example.com", refreshToken: "refresh-token", addedAt: 1, lastUsed: 1 }] }), + lookupCodexCliTokensByEmail: async () => null, + queuedRefresh: async () => ({ type: "success", access: "new-access", refresh: "new-refresh", expires: Date.now() + 60_000 }), + resolveAccountSelection: (tokens) => ({ refreshToken: tokens.refresh, accessToken: tokens.access }) as never, + persistAccounts: vi.fn(async () => { calls.push("persistAccounts"); }), + invalidateAccountManagerCache: vi.fn(), + saveFlaggedAccounts: vi.fn(async () => { calls.push("saveFlaggedAccounts"); }), + logInfo: vi.fn(), + showLine: vi.fn(), + }); + expect(calls).toEqual(["saveFlaggedAccounts", "persistAccounts"]); + }); + + it("keeps flagged state saved when persistAccounts throws EBUSY", async () => { + const saveFlaggedAccounts = vi.fn(async () => {}); + const persistAccounts = vi.fn(async () => { + const error = new Error("busy") as Error & { code?: string }; + error.code = "EBUSY"; + throw error; + }); + await expect( + verifyRuntimeFlaggedAccounts({ + loadFlaggedAccounts: async () => ({ version: 1, accounts: [{ email: "refresh@example.com", refreshToken: "refresh-token", addedAt: 1, lastUsed: 1 }] }), + lookupCodexCliTokensByEmail: async () => null, + queuedRefresh: async () => ({ type: "success", access: "new-access", refresh: "new-refresh", expires: Date.now() + 60_000 }), + resolveAccountSelection: (tokens) => ({ refreshToken: tokens.refresh, accessToken: tokens.access }) as never, + persistAccounts, + invalidateAccountManagerCache: vi.fn(), + saveFlaggedAccounts, + logInfo: vi.fn(), + showLine: vi.fn(), + }), + ).rejects.toThrow("busy"); + expect(saveFlaggedAccounts).toHaveBeenCalledTimes(1); + }); }); From e42fa898539605b6c1ef789c413f8a87b17ef7c8 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 05:11:18 +0800 Subject: [PATCH 232/376] fix: normalize pack budget paths --- scripts/check-pack-budget.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/check-pack-budget.mjs b/scripts/check-pack-budget.mjs index 96ac0b74..eb32a6ff 100644 --- a/scripts/check-pack-budget.mjs +++ b/scripts/check-pack-budget.mjs @@ -56,7 +56,8 @@ if (packageSize > MAX_PACKAGE_SIZE) { const paths = pack.files .map((/** @type {{ path?: unknown }} */ file) => file.path) - .filter((/** @type {unknown} */ value) => typeof value === "string"); + .filter((/** @type {unknown} */ value) => typeof value === "string") + .map((/** @type {string} */ value) => value.replaceAll("\\", "/")); for (const forbidden of FORBIDDEN_PREFIXES) { const leaked = paths.find( From 629d016658413a57592ae793cc40fe454449186f Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 05:13:38 +0800 Subject: [PATCH 233/376] fix: harden debug bundle loading --- lib/codex-manager/commands/debug-bundle.ts | 97 ++++++++++++---------- test/codex-manager-cli.test.ts | 89 +++++++++++++++++++- 2 files changed, 137 insertions(+), 49 deletions(-) diff --git a/lib/codex-manager/commands/debug-bundle.ts b/lib/codex-manager/commands/debug-bundle.ts index 8e6d9770..da76d685 100644 --- a/lib/codex-manager/commands/debug-bundle.ts +++ b/lib/codex-manager/commands/debug-bundle.ts @@ -37,53 +37,60 @@ export function runDebugBundleCommand( deps.loadAccounts(), deps.loadFlaggedAccounts(), deps.loadCodexCliState({ forceRefresh: true }), - ]).then(([config, accounts, flagged, codexCli]) => { - const bundle = { - generatedAt: new Date().toISOString(), - storagePath: deps.getStoragePath(), - lastAccountsSaveTimestamp: deps.getLastAccountsSaveTimestamp(), - config, - accounts: { - total: accounts?.accounts.length ?? 0, - enabled: - accounts?.accounts.filter((account) => account.enabled !== false) - .length ?? 0, - activeIndex: - typeof accounts?.activeIndex === "number" - ? accounts.activeIndex + 1 - : null, - }, - flaggedAccounts: { - total: flagged.accounts.length, - }, - codexCli: codexCli - ? { - path: codexCli.path, - accountCount: codexCli.accounts.length, - activeEmail: codexCli.activeEmail ?? null, - activeAccountId: codexCli.activeAccountId ?? null, - syncVersion: codexCli.syncVersion ?? null, - sourceUpdatedAtMs: codexCli.sourceUpdatedAtMs ?? null, - } - : null, - }; + ]) + .then(([config, accounts, flagged, codexCli]) => { + const bundle = { + generatedAt: new Date().toISOString(), + storagePath: deps.getStoragePath(), + lastAccountsSaveTimestamp: deps.getLastAccountsSaveTimestamp(), + config, + accounts: { + total: accounts?.accounts.length ?? 0, + enabled: + accounts?.accounts.filter((account) => account.enabled !== false) + .length ?? 0, + activeIndex: + typeof accounts?.activeIndex === "number" + ? accounts.activeIndex + 1 + : null, + }, + flaggedAccounts: { + total: flagged.accounts.length, + }, + codexCli: codexCli + ? { + path: codexCli.path, + accountCount: codexCli.accounts.length, + activeEmail: codexCli.activeEmail ?? null, + activeAccountId: codexCli.activeAccountId ?? null, + syncVersion: codexCli.syncVersion ?? null, + sourceUpdatedAtMs: codexCli.sourceUpdatedAtMs ?? null, + } + : null, + }; - if (json) { - logInfo(JSON.stringify(bundle, null, 2)); - return 0; - } + if (json) { + logInfo(JSON.stringify(bundle, null, 2)); + return 0; + } - logInfo(`Generated: ${bundle.generatedAt}`); - logInfo(`Storage: ${bundle.storagePath}`); - logInfo( - `Accounts: ${bundle.accounts.total} total, ${bundle.accounts.enabled} enabled`, - ); - logInfo(`Flagged: ${bundle.flaggedAccounts.total}`); - if (bundle.codexCli) { + logInfo(`Generated: ${bundle.generatedAt}`); + logInfo(`Storage: ${bundle.storagePath}`); logInfo( - `Codex CLI: ${bundle.codexCli.accountCount} account(s), active ${bundle.codexCli.activeEmail ?? "unknown"}`, + `Accounts: ${bundle.accounts.total} total, ${bundle.accounts.enabled} enabled`, + ); + logInfo(`Flagged: ${bundle.flaggedAccounts.total}`); + if (bundle.codexCli) { + logInfo( + `Codex CLI: ${bundle.codexCli.accountCount} account(s), active ${bundle.codexCli.activeEmail ?? "unknown"}`, + ); + } + return 0; + }) + .catch((error) => { + logError( + `Failed to generate debug bundle: ${error instanceof Error ? error.message : String(error)}`, ); - } - return 0; - }); + return 1; + }); } diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 14d4d4d5..a39ff577 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -10,6 +10,7 @@ const getNamedBackupsMock = vi.fn(); const restoreAccountsFromBackupMock = vi.fn(); const queuedRefreshMock = vi.fn(); const setCodexCliActiveSelectionMock = vi.fn(); +const loadCodexCliStateMock = vi.fn(); const promptAddAnotherAccountMock = vi.fn(); const promptLoginModeMock = vi.fn(); const fetchCodexQuotaSnapshotMock = vi.fn(); @@ -152,6 +153,12 @@ vi.mock("../lib/codex-cli/writer.js", () => ({ setCodexCliActiveSelection: setCodexCliActiveSelectionMock, })); +vi.mock("../lib/codex-cli/state.js", () => ({ + getCodexCliAuthPath: vi.fn(() => "/mock/.codex/auth.json"), + getCodexCliConfigPath: vi.fn(() => "/mock/.codex/config.toml"), + loadCodexCliState: loadCodexCliStateMock, +})); + vi.mock("../lib/quota-probe.js", () => ({ fetchCodexQuotaSnapshot: fetchCodexQuotaSnapshotMock, formatQuotaSnapshotLine: vi.fn(() => "probe-ok"), @@ -482,6 +489,7 @@ describe("codex manager cli commands", () => { withAccountStorageTransactionMock.mockReset(); queuedRefreshMock.mockReset(); setCodexCliActiveSelectionMock.mockReset(); + loadCodexCliStateMock.mockReset(); promptAddAnotherAccountMock.mockReset(); promptLoginModeMock.mockReset(); fetchCodexQuotaSnapshotMock.mockReset(); @@ -508,6 +516,7 @@ describe("codex manager cli commands", () => { version: 1, accounts: [], }); + loadCodexCliStateMock.mockResolvedValue(null); withAccountStorageTransactionMock.mockImplementation(async (handler) => { const current = await loadAccountsMock(); return handler( @@ -732,6 +741,14 @@ describe("codex manager cli commands", () => { version: 1, accounts: [{ refreshToken: "flagged-1" }], }); + loadCodexCliStateMock.mockResolvedValueOnce({ + path: "/mock/.codex/state.json", + accounts: [{ email: "codex@example.com" }], + activeEmail: "codex@example.com", + activeAccountId: "acc_codex", + syncVersion: 7, + sourceUpdatedAtMs: 1_710_000_000_000, + }); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); @@ -743,10 +760,74 @@ describe("codex manager cli commands", () => { ]); expect(exitCode).toBe(0); - expect(logSpy).toHaveBeenCalledWith( - expect.stringContaining('"storagePath"'), - ); - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"codexCli"')); + expect(loadCodexCliStateMock).toHaveBeenCalledWith({ + forceRefresh: true, + }); + expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({ + storagePath: "/mock/openai-codex-accounts.json", + flaggedAccounts: { total: 1 }, + codexCli: { + path: "/mock/.codex/state.json", + accountCount: 1, + activeEmail: "codex@example.com", + activeAccountId: "acc_codex", + syncVersion: 7, + sourceUpdatedAtMs: 1_710_000_000_000, + }, + }); + }); + + it.each([ + ["flagged accounts", () => + loadFlaggedAccountsMock.mockRejectedValueOnce( + new Error("flagged storage unavailable"), + )], + ["codex cli state", () => + loadCodexCliStateMock.mockRejectedValueOnce( + new Error("codex cli state unavailable"), + )], + ])( + "returns an error when debug bundle loading fails for %s", + async (_label, primeFailure) => { + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + accounts: [{ refreshToken: "token-1", enabled: true }], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }); + primeFailure(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli([ + "auth", + "debug", + "bundle", + "--json", + ]); + + expect(exitCode).toBe(1); + expect(logSpy).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to generate debug bundle:"), + ); + }, + ); + + it("rejects unknown debug bundle args", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli([ + "auth", + "debug", + "bundle", + "--bogus", + ]); + + expect(exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith("Unknown option: --bogus"); }); it("prints populated account status for auth status", async () => { From d49a9067dfb426f763bec919d19ab79636f9298b Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 05:16:25 +0800 Subject: [PATCH 234/376] test: cover config explain sources --- test/config-explain.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/config-explain.test.ts b/test/config-explain.test.ts index 0287b7a2..ddd02f65 100644 --- a/test/config-explain.test.ts +++ b/test/config-explain.test.ts @@ -13,6 +13,7 @@ vi.mock("../lib/unified-settings.js", () => ({ describe("getPluginConfigExplainReport", () => { afterEach(async () => { + delete process.env.CODEX_MODE; delete process.env.CODEX_AUTH_FAST_SESSION_STRATEGY; delete process.env.CODEX_MULTI_AUTH_CONFIG_PATH; loadUnifiedPluginConfigSyncMock.mockReset(); @@ -20,6 +21,22 @@ describe("getPluginConfigExplainReport", () => { vi.resetModules(); }); + it('marks entries sourced from unified settings as "unified"', async () => { + loadUnifiedPluginConfigSyncMock.mockReturnValue({ + unsupportedCodexPolicy: "fallback", + }); + const { getPluginConfigExplainReport } = await import("../lib/config.js"); + + const report = getPluginConfigExplainReport(); + const entry = report.entries.find( + (item) => item.key === "unsupportedCodexPolicy", + ); + + expect(report.storageKind).toBe("unified"); + expect(entry).toBeDefined(); + expect(entry?.source).toBe("unified"); + }); + it("treats invalid string env values as non-env sources", async () => { process.env.CODEX_AUTH_FAST_SESSION_STRATEGY = "bogus"; const { getPluginConfigExplainReport } = await import("../lib/config.js"); From 9416689b6ec80da7199b5a6fca9f87893051851b Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 05:21:07 +0800 Subject: [PATCH 235/376] fix: preserve sparse capability boost slots --- lib/runtime/capability-boost.ts | 9 ++++- test/capability-boost.test.ts | 70 +++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 test/capability-boost.test.ts diff --git a/lib/runtime/capability-boost.ts b/lib/runtime/capability-boost.ts index 0e5817ba..9b3f5ad9 100644 --- a/lib/runtime/capability-boost.ts +++ b/lib/runtime/capability-boost.ts @@ -25,7 +25,7 @@ export function buildCapabilityBoostByAccount(input: { }; getBoost: (accountKey: string, capabilityKey: string) => number; }): number[] { - const boosts = new Array(Math.max(0, input.accountCount)).fill(0); + const boosts = new Array(Math.max(0, input.accountCount)); const accountSnapshotList = typeof input.accountSnapshotSource.getAccountsSnapshot === "function" ? (input.accountSnapshotSource.getAccountsSnapshot() ?? []) @@ -47,6 +47,13 @@ export function buildCapabilityBoostByAccount(input: { } for (const candidate of accountSnapshotList) { + if ( + !Number.isInteger(candidate.index) || + candidate.index < 0 || + candidate.index >= boosts.length + ) { + continue; + } const accountKey = resolveEntitlementAccountKey(candidate); boosts[candidate.index] = input.getBoost( accountKey, diff --git a/test/capability-boost.test.ts b/test/capability-boost.test.ts new file mode 100644 index 00000000..6f568d6c --- /dev/null +++ b/test/capability-boost.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildCapabilityBoostByAccount } from "../lib/runtime/capability-boost.js"; + +describe("buildCapabilityBoostByAccount", () => { + it("keeps sparse slots and prefers model over modelFamily for snapshots", () => { + const getBoost = vi.fn().mockReturnValueOnce(5).mockReturnValueOnce(7); + + const boosts = buildCapabilityBoostByAccount({ + accountCount: 4, + model: "gpt-5-codex", + modelFamily: "codex", + accountSnapshotSource: { + getAccountsSnapshot: () => [ + { index: 1, accountId: "acc_1" }, + { index: 3, email: "User@example.com" }, + ], + }, + getBoost, + }); + + expect(boosts).toHaveLength(4); + expect(0 in boosts).toBe(false); + expect(boosts[1]).toBe(5); + expect(2 in boosts).toBe(false); + expect(boosts[3]).toBe(7); + expect(getBoost.mock.calls).toEqual([ + ["account:acc_1::idx:1", "gpt-5-codex"], + ["email:user@example.com", "gpt-5-codex"], + ]); + }); + + it("falls back to getAccountByIndex and skips invalid snapshot indices", () => { + const getBoost = vi.fn().mockReturnValueOnce(11).mockReturnValueOnce(22); + const getAccountByIndex = vi.fn((index: number) => { + switch (index) { + case 0: + return { index: 0, accountId: "acc_0" }; + case 1: + return { index: -1, email: "ignored-negative@example.com" }; + case 2: + return { index: 5, email: "ignored-out-of-range@example.com" }; + case 3: + return { index: 2, email: "final@example.com" }; + default: + return null; + } + }); + + const boosts = buildCapabilityBoostByAccount({ + accountCount: 4, + modelFamily: "codex", + accountSnapshotSource: { + getAccountsSnapshot: () => [], + getAccountByIndex, + }, + getBoost, + }); + + expect(boosts).toHaveLength(4); + expect(boosts[0]).toBe(11); + expect(1 in boosts).toBe(false); + expect(boosts[2]).toBe(22); + expect(3 in boosts).toBe(false); + expect(getAccountByIndex).toHaveBeenCalledTimes(4); + expect(getBoost.mock.calls).toEqual([ + ["account:acc_0::idx:0", "codex"], + ["email:final@example.com", "codex"], + ]); + }); +}); From 24a74f08e7a9bd67d14dad5f497880c1044a2fc2 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 05:24:25 +0800 Subject: [PATCH 236/376] fix: serialize account select events --- index.ts | 2 +- lib/runtime/account-select-event.ts | 96 ++++++++++-------- test/account-select-event.test.ts | 149 ++++++++++++++++++++++++++++ 3 files changed, 207 insertions(+), 40 deletions(-) create mode 100644 test/account-select-event.test.ts diff --git a/index.ts b/index.ts index 738fa590..1b6d4b8b 100644 --- a/index.ts +++ b/index.ts @@ -545,7 +545,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { loadAccounts, saveAccounts, modelFamilies: MODEL_FAMILIES, - cachedAccountManager, + getCachedAccountManager: () => cachedAccountManager, reloadAccountManagerFromDisk: async () => { await reloadAccountManagerFromDisk(); }, diff --git a/lib/runtime/account-select-event.ts b/lib/runtime/account-select-event.ts index acdac97b..1bcd170b 100644 --- a/lib/runtime/account-select-event.ts +++ b/lib/runtime/account-select-event.ts @@ -1,13 +1,26 @@ import type { ModelFamily } from "../prompts/codex.js"; import type { AccountStorageV3 } from "../storage.js"; +let accountSelectWriteQueue: Promise = Promise.resolve(); + +function serializeAccountSelectMutation( + task: () => Promise, +): Promise { + const run = accountSelectWriteQueue.then(task, task); + accountSelectWriteQueue = run.then( + () => undefined, + () => undefined, + ); + return run; +} + export async function handleAccountSelectEvent(input: { event: { type: string; properties?: unknown }; providerId: string; loadAccounts: () => Promise; saveAccounts: (storage: AccountStorageV3) => Promise; modelFamilies: readonly ModelFamily[]; - cachedAccountManager: { + getCachedAccountManager: () => { syncCodexCliActiveSelectionForIndex(index: number): Promise; } | null; reloadAccountManagerFromDisk: () => Promise; @@ -25,49 +38,54 @@ export async function handleAccountSelectEvent(input: { return false; } - const props = event.properties as { - index?: number; - accountIndex?: number; - provider?: string; - }; - if ( - props.provider && - props.provider !== "openai" && - props.provider !== input.providerId - ) { - return true; + const props = + typeof event.properties === "object" && event.properties !== null + ? (event.properties as { + index?: unknown; + accountIndex?: unknown; + provider?: unknown; + }) + : {}; + const provider = + typeof props.provider === "string" ? props.provider : undefined; + if (provider && provider !== "openai" && provider !== input.providerId) { + return false; } - const index = props.index ?? props.accountIndex; - if (typeof index !== "number") return true; + const rawIndex = props.index ?? props.accountIndex; + if (!Number.isInteger(rawIndex)) return true; + const index = rawIndex as number; - const storage = await input.loadAccounts(); - if (!storage || index < 0 || index >= storage.accounts.length) { - return true; - } + return serializeAccountSelectMutation(async () => { + const storage = await input.loadAccounts(); + if (!storage || index < 0 || index >= storage.accounts.length) { + return true; + } - const now = Date.now(); - const account = storage.accounts[index]; - if (account) { - account.lastUsed = now; - account.lastSwitchReason = "rotation"; - } - storage.activeIndex = index; - storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; - for (const family of input.modelFamilies) { - storage.activeIndexByFamily[family] = index; - } + const now = Date.now(); + const account = storage.accounts[index]; + if (account) { + account.lastUsed = now; + account.lastSwitchReason = "rotation"; + } + storage.activeIndex = index; + storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; + for (const family of input.modelFamilies) { + storage.activeIndexByFamily[family] = index; + } - await input.saveAccounts(storage); - if (input.cachedAccountManager) { - await input.cachedAccountManager.syncCodexCliActiveSelectionForIndex(index); - } - input.setLastCodexCliActiveSyncIndex(index); + await input.saveAccounts(storage); + const manager = input.getCachedAccountManager(); + if (manager) { + await manager.syncCodexCliActiveSelectionForIndex(index); + } + input.setLastCodexCliActiveSyncIndex(index); - if (input.cachedAccountManager) { - await input.reloadAccountManagerFromDisk(); - } + if (input.getCachedAccountManager()) { + await input.reloadAccountManagerFromDisk(); + } - await input.showToast(`Switched to account ${index + 1}`, "info"); - return true; + await input.showToast(`Switched to account ${index + 1}`, "info"); + return true; + }); } diff --git a/test/account-select-event.test.ts b/test/account-select-event.test.ts new file mode 100644 index 00000000..37dc1875 --- /dev/null +++ b/test/account-select-event.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from "vitest"; +import { handleAccountSelectEvent } from "../lib/runtime/account-select-event.js"; + +function createStorage() { + return { + version: 3 as const, + activeIndex: 0, + activeIndexByFamily: {} as Record, + accounts: [ + { refreshToken: "refresh-0", email: "zero@example.com" }, + { refreshToken: "refresh-1", email: "one@example.com" }, + ], + }; +} + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +describe("handleAccountSelectEvent", () => { + it("returns false for events owned by a different provider", async () => { + const loadAccounts = vi.fn(); + + const handled = await handleAccountSelectEvent({ + event: { + type: "account.select", + properties: { provider: "other", index: 0 }, + }, + providerId: "codex", + loadAccounts, + saveAccounts: vi.fn(), + modelFamilies: ["codex"], + getCachedAccountManager: () => null, + reloadAccountManagerFromDisk: vi.fn(), + setLastCodexCliActiveSyncIndex: vi.fn(), + showToast: vi.fn(), + }); + + expect(handled).toBe(false); + expect(loadAccounts).not.toHaveBeenCalled(); + }); + + it.each([ + ["missing properties", undefined], + ["NaN index", { index: Number.NaN }], + ["fractional index", { index: 1.5 }], + ])("ignores invalid %s without touching storage", async (_label, properties) => { + const loadAccounts = vi.fn(); + const saveAccounts = vi.fn(); + + const handled = await handleAccountSelectEvent({ + event: { type: "account.select", properties }, + providerId: "openai", + loadAccounts, + saveAccounts, + modelFamilies: ["codex"], + getCachedAccountManager: () => null, + reloadAccountManagerFromDisk: vi.fn(), + setLastCodexCliActiveSyncIndex: vi.fn(), + showToast: vi.fn(), + }); + + expect(handled).toBe(true); + expect(loadAccounts).not.toHaveBeenCalled(); + expect(saveAccounts).not.toHaveBeenCalled(); + }); + + it("uses the latest cached account manager after save", async () => { + const loadAccounts = vi.fn(async () => createStorage()); + let manager: + | { syncCodexCliActiveSelectionForIndex(index: number): Promise } + | null = null; + const syncCodexCliActiveSelectionForIndex = vi.fn(async () => {}); + const reloadAccountManagerFromDisk = vi.fn(async () => {}); + const setLastCodexCliActiveSyncIndex = vi.fn(); + const showToast = vi.fn(async () => {}); + const saveAccounts = vi.fn(async () => { + manager = { syncCodexCliActiveSelectionForIndex }; + }); + + const handled = await handleAccountSelectEvent({ + event: { type: "account.select", properties: { index: 1 } }, + providerId: "openai", + loadAccounts, + saveAccounts, + modelFamilies: ["codex"], + getCachedAccountManager: () => manager, + reloadAccountManagerFromDisk, + setLastCodexCliActiveSyncIndex, + showToast, + }); + + expect(handled).toBe(true); + expect(syncCodexCliActiveSelectionForIndex).toHaveBeenCalledWith(1); + expect(setLastCodexCliActiveSyncIndex).toHaveBeenCalledWith(1); + expect(reloadAccountManagerFromDisk).toHaveBeenCalledTimes(1); + }); + + it("serializes concurrent account.select writes", async () => { + let currentStorage = createStorage(); + const firstSaveStarted = createDeferred(); + const releaseFirstSave = createDeferred(); + const loadAccounts = vi.fn(async () => structuredClone(currentStorage)); + const saveAccounts = vi.fn(async (storage: typeof currentStorage) => { + currentStorage = structuredClone(storage); + if (saveAccounts.mock.calls.length === 1) { + firstSaveStarted.resolve(); + await releaseFirstSave.promise; + } + }); + + const baseInput = { + providerId: "openai", + loadAccounts, + saveAccounts, + modelFamilies: ["codex"] as const, + getCachedAccountManager: () => null, + reloadAccountManagerFromDisk: vi.fn(async () => {}), + setLastCodexCliActiveSyncIndex: vi.fn(), + showToast: vi.fn(async () => {}), + }; + + const first = handleAccountSelectEvent({ + ...baseInput, + event: { type: "account.select", properties: { index: 0 } }, + }); + await firstSaveStarted.promise; + + const second = handleAccountSelectEvent({ + ...baseInput, + event: { type: "account.select", properties: { index: 1 } }, + }); + + await Promise.resolve(); + expect(loadAccounts).toHaveBeenCalledTimes(1); + + releaseFirstSave.resolve(); + await Promise.all([first, second]); + + expect(loadAccounts).toHaveBeenCalledTimes(2); + expect(saveAccounts).toHaveBeenCalledTimes(2); + expect(currentStorage.activeIndex).toBe(1); + expect(currentStorage.activeIndexByFamily.codex).toBe(1); + }); +}); From 876c0285b376bef40cd70916fcd7bc66ed1a1c7d Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 05:30:15 +0800 Subject: [PATCH 237/376] fix: handle init-config template read failures --- lib/codex-manager/commands/init-config.ts | 10 ++++++- test/init-config-command.test.ts | 34 ++++++++++++++++++++--- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/lib/codex-manager/commands/init-config.ts b/lib/codex-manager/commands/init-config.ts index 6a377473..7be3b30b 100644 --- a/lib/codex-manager/commands/init-config.ts +++ b/lib/codex-manager/commands/init-config.ts @@ -90,7 +90,15 @@ export async function runInitConfigCommand( await writeFile(path, content, "utf8"); }); - const content = await readTemplate(parsed.template); + let content: string; + try { + content = await readTemplate(parsed.template); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to read config template"; + logError(message); + return 1; + } if (parsed.stdout || !parsed.writePath) { logInfo(content.trimEnd()); diff --git a/test/init-config-command.test.ts b/test/init-config-command.test.ts index 6f64b7bf..9a69edc4 100644 --- a/test/init-config-command.test.ts +++ b/test/init-config-command.test.ts @@ -1,3 +1,4 @@ +import { resolve } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; describe("runInitConfigCommand", () => { @@ -16,9 +17,16 @@ describe("runInitConfigCommand", () => { readFile: readFileMock, writeFile: vi.fn(), })); + const distCommandPath = resolve( + "/repo", + "dist", + "lib", + "codex-manager", + "commands", + "init-config.js", + ); vi.doMock("node:url", () => ({ - fileURLToPath: () => - "C:\\repo\\dist\\lib\\codex-manager\\commands\\init-config.js", + fileURLToPath: () => distCommandPath, })); const { runInitConfigCommand } = await import( @@ -28,16 +36,34 @@ describe("runInitConfigCommand", () => { const exitCode = await runInitConfigCommand(["modern"], { logInfo, logError: vi.fn(), - cwd: () => "C:\\repo", + cwd: () => resolve("/repo"), }); expect(exitCode).toBe(0); expect(readFileMock).toHaveBeenCalledWith( - "C:\\repo\\config\\codex-modern.json", + resolve("/repo", "config", "codex-modern.json"), "utf8", ); expect(logInfo).toHaveBeenCalledWith( expect.stringContaining('"plugin": ["codex-multi-auth"]'), ); }); + + it("logs and returns 1 when template loading fails", async () => { + const { runInitConfigCommand } = await import( + "../lib/codex-manager/commands/init-config.js" + ); + const logError = vi.fn(); + + const exitCode = await runInitConfigCommand(["modern"], { + logInfo: vi.fn(), + logError, + readTemplate: async () => { + throw new Error("template missing"); + }, + }); + + expect(exitCode).toBe(1); + expect(logError).toHaveBeenCalledWith("template missing"); + }); }); From 3d2947fc8ad44730dc1897accbff6e5e0f9298e1 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 05:37:56 +0800 Subject: [PATCH 238/376] fix: guard repair command persistence drift --- lib/codex-manager/repair-commands.ts | 66 +++++++++++++++++++++----- test/repair-commands.test.ts | 70 +++++++++++++++++++++++++++- 2 files changed, 123 insertions(+), 13 deletions(-) diff --git a/lib/codex-manager/repair-commands.ts b/lib/codex-manager/repair-commands.ts index 019233cf..65cb343d 100644 --- a/lib/codex-manager/repair-commands.ts +++ b/lib/codex-manager/repair-commands.ts @@ -342,6 +342,8 @@ type AccountStorageMutation = { }; type FlaggedStorageMutation = { + index: number; + label: string; before: FlaggedAccountMetadataV1; after?: FlaggedAccountMetadataV1; }; @@ -903,6 +905,8 @@ export async function runVerifyFlagged( flaggedChanged = true; } flaggedMutations.push({ + index: i, + label, before: flagged, after: nextFlagged, }); @@ -926,6 +930,8 @@ export async function runVerifyFlagged( storageChanged = storageChanged || upsertResult.changed; flaggedChanged = true; flaggedMutations.push({ + index: i, + label, before: flagged, }); reports.push({ @@ -961,6 +967,8 @@ export async function runVerifyFlagged( flaggedChanged = true; } flaggedMutations.push({ + index: i, + label, before: flagged, after: updatedFlagged, }); @@ -983,6 +991,8 @@ export async function runVerifyFlagged( flaggedChanged = true; } flaggedMutations.push({ + index: i, + label, before: flagged, after: failedFlagged, }); @@ -1049,6 +1059,45 @@ export async function runVerifyFlagged( remainingFlagged = nextFlaggedAccounts.length; } + if (!options.dryRun && !options.restore && flaggedChanged) { + await withFlaggedStorageTransaction(async (loadedFlaggedStorage, persist) => { + const nextFlaggedStorage = structuredClone(loadedFlaggedStorage); + const staleFlaggedMutations = flaggedMutations.filter((mutation) => + hasFlaggedRefreshTokenDrift(nextFlaggedStorage.accounts, mutation.before), + ); + const safeFlaggedMutations = flaggedMutations.filter( + (mutation) => + !hasFlaggedRefreshTokenDrift(nextFlaggedStorage.accounts, mutation.before), + ); + for (const mutation of staleFlaggedMutations) { + const staleReport = reports.find( + (report) => + report.index === mutation.index && report.label === mutation.label, + ); + if (staleReport) { + staleReport.outcome = "restore-skipped"; + staleReport.message = + "Skipped flagged update because refresh token changed before persistence"; + continue; + } + reports.push({ + index: mutation.index, + label: mutation.label, + outcome: "restore-skipped", + message: + "Skipped flagged update because refresh token changed before persistence", + }); + } + applyFlaggedStorageMutations(nextFlaggedStorage, safeFlaggedMutations); + remainingFlagged = nextFlaggedStorage.accounts.length; + if (safeFlaggedMutations.length === 0) { + flaggedChanged = false; + return; + } + await persist(nextFlaggedStorage); + }); + } + const restored = reports.filter((report) => report.outcome === "restored").length; const healthyFlagged = reports.filter( (report) => report.outcome === "healthy-flagged", @@ -1058,15 +1107,6 @@ export async function runVerifyFlagged( ).length; const changed = storageChanged || flaggedChanged; - if (!options.dryRun && !options.restore && flaggedChanged) { - await withFlaggedStorageTransaction(async (loadedFlaggedStorage, persist) => { - const nextFlaggedStorage = structuredClone(loadedFlaggedStorage); - applyFlaggedStorageMutations(nextFlaggedStorage, flaggedMutations); - remainingFlagged = nextFlaggedStorage.accounts.length; - await persist(nextFlaggedStorage); - }); - } - if (options.json) { console.log( JSON.stringify( @@ -1558,10 +1598,14 @@ export async function runFix( await saveQuotaCache(workingQuotaCache); } - if (anyChanged && options.dryRun) { + if (accountStorageChanged && options.dryRun) { console.log(`\n${deps.stylePromptText("Preview only: no changes were saved.", "warning")}`); - } else if (anyChanged) { + } else if (accountStorageChanged) { console.log(`\n${deps.stylePromptText("Saved updates.", "success")}`); + } else if (quotaCacheChanged) { + console.log( + `\n${deps.stylePromptText("Quota cache refreshed (no account storage changes).", "muted")}`, + ); } else { console.log(`\n${deps.stylePromptText("No changes were needed.", "muted")}`); } diff --git a/test/repair-commands.test.ts b/test/repair-commands.test.ts index fddc949d..0853aee7 100644 --- a/test/repair-commands.test.ts +++ b/test/repair-commands.test.ts @@ -340,6 +340,71 @@ describe("repair-commands direct deps coverage", () => { }); }); + it("runVerifyFlagged skips stale no-restore updates when flagged refresh tokens changed before persistence", async () => { + const flaggedAccount = { + email: "flagged@example.com", + refreshToken: "flagged-refresh", + accessToken: "old-access", + expiresAt: 10, + accountId: "stored-account", + accountIdSource: "manual" as const, + lastError: "old-error", + lastUsed: 1, + }; + const persistSpy = vi.fn(); + + loadFlaggedAccountsMock.mockResolvedValue({ + version: 1, + accounts: [structuredClone(flaggedAccount)], + }); + queuedRefreshMock.mockResolvedValue({ + type: "success", + access: "fresh-access", + refresh: "fresh-refresh", + expires: 999, + idToken: "fresh-id-token", + }); + extractAccountEmailMock.mockReturnValue("flagged@example.com"); + extractAccountIdMock.mockReturnValue("token-account"); + withFlaggedStorageTransactionMock.mockImplementation(async (handler) => + handler( + { + version: 1, + accounts: [ + { + ...structuredClone(flaggedAccount), + refreshToken: "rotated-refresh", + }, + ], + }, + persistSpy, + ), + ); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const exitCode = await runVerifyFlagged( + ["--json", "--no-restore"], + createDeps(), + ); + + expect(exitCode).toBe(0); + expect(withFlaggedStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(persistSpy).not.toHaveBeenCalled(); + expect( + JSON.parse(String(consoleSpy.mock.calls.at(-1)?.[0] ?? "{}")), + ).toMatchObject({ + total: 1, + remainingFlagged: 1, + changed: false, + reports: [ + expect.objectContaining({ + outcome: "restore-skipped", + message: expect.stringContaining("changed before persistence"), + }), + ], + }); + }); + it("runFix uses the injected token-identity applier in the direct concurrent-write path", async () => { const prescanStorage = { version: 3, @@ -509,7 +574,7 @@ describe("repair-commands direct deps coverage", () => { }); }); - it("runFix reports saved updates for quota-cache-only live changes in display mode", async () => { + it("runFix reports quota-cache-only live changes distinctly in display mode", async () => { const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); loadQuotaCacheMock.mockResolvedValueOnce({ @@ -553,7 +618,8 @@ describe("repair-commands direct deps coverage", () => { const output = consoleSpy.mock.calls .map((call) => call.map((value) => String(value)).join(" ")) .join("\n"); - expect(output).toContain("Saved updates."); + expect(output).toContain("Quota cache refreshed (no account storage changes)."); + expect(output).not.toContain("Saved updates."); expect(output).not.toContain("No changes were needed."); }); From aaf14f5ea338780123851f9077c9f186e2bc323a Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 05:49:31 +0800 Subject: [PATCH 239/376] fix: retry flagged cache lookups safely --- lib/runtime/verify-flagged.ts | 12 ++++++------ test/runtime-verify-flagged.test.ts | 22 +++++++++++----------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/lib/runtime/verify-flagged.ts b/lib/runtime/verify-flagged.ts index 418e4a1e..acd46bbc 100644 --- a/lib/runtime/verify-flagged.ts +++ b/lib/runtime/verify-flagged.ts @@ -67,7 +67,7 @@ export async function verifyRuntimeFlaggedAccounts(deps: { const maskedEmail = flagged.email ? maskEmail(flagged.email) : undefined; const label = flagged.accountLabel ?? maskedEmail ?? `Flagged ${i + 1}`; try { - const cached = await deps.lookupCodexCliTokensByEmail(flagged.email); + const cached = await deps.lookupCodexCliTokensByEmail(flagged.email).catch(() => null); const now = deps.now?.() ?? Date.now(); if ( cached && @@ -139,16 +139,16 @@ export async function verifyRuntimeFlaggedAccounts(deps: { } } - await deps.saveFlaggedAccounts({ - version: 1, - accounts: state.remaining, - }); - if (state.restored.length > 0) { await deps.persistAccounts(state.restored, false); deps.invalidateAccountManagerCache(); } + await deps.saveFlaggedAccounts({ + version: 1, + accounts: state.remaining, + }); + deps.showLine(""); deps.showLine( `Results: ${state.restored.length} restored, ${state.remaining.length} still flagged`, diff --git a/test/runtime-verify-flagged.test.ts b/test/runtime-verify-flagged.test.ts index 63d5d680..469534f4 100644 --- a/test/runtime-verify-flagged.test.ts +++ b/test/runtime-verify-flagged.test.ts @@ -6,7 +6,7 @@ describe("verifyRuntimeFlaggedAccounts", () => { const showLine = vi.fn(); await verifyRuntimeFlaggedAccounts({ loadFlaggedAccounts: async () => ({ version: 1, accounts: [] }), - lookupCodexCliTokensByEmail: async () => null, + lookupCodexCliTokensByEmail: async () => { throw new Error("busy"); }, queuedRefresh: async () => ({ type: "failed", reason: "invalid_grant" }), resolveAccountSelection: () => ({}) as never, persistAccounts: vi.fn(async () => {}), @@ -74,9 +74,9 @@ describe("verifyRuntimeFlaggedAccounts", () => { const saveFlaggedAccounts = vi.fn(async () => {}); await verifyRuntimeFlaggedAccounts({ loadFlaggedAccounts: async () => ({ version: 1, accounts: [{ email: "broken@example.com", refreshToken: "broken-refresh", addedAt: 1, lastUsed: 1 }] }), - lookupCodexCliTokensByEmail: async () => { throw new Error("cache unavailable"); }, + lookupCodexCliTokensByEmail: async () => ({ accessToken: "cached-access", refreshToken: "cached-refresh", expiresAt: Date.now() + 60_000 }), queuedRefresh: async () => ({ type: "failed", reason: "invalid_grant" }), - resolveAccountSelection: () => ({}) as never, + resolveAccountSelection: () => { throw new Error("selection failed"); }, persistAccounts: vi.fn(async () => {}), invalidateAccountManagerCache: vi.fn(), saveFlaggedAccounts, @@ -84,17 +84,17 @@ describe("verifyRuntimeFlaggedAccounts", () => { logError, showLine: vi.fn(), }); - expect(logError).toHaveBeenCalledWith(expect.stringContaining("Failed to verify flagged account br***@***.com: cache unavailable")); + expect(logError).toHaveBeenCalledWith(expect.stringContaining("Failed to verify flagged account br***@***.com: selection failed")); expect(saveFlaggedAccounts).toHaveBeenCalledWith({ version: 1, - accounts: [expect.objectContaining({ refreshToken: "broken-refresh", lastError: "cache unavailable" })], + accounts: [expect.objectContaining({ refreshToken: "broken-refresh", lastError: "selection failed" })], }); }); - it("writes flagged state before restored accounts", async () => { + it("writes restored accounts before flagged state cleanup", async () => { const calls: string[] = []; await verifyRuntimeFlaggedAccounts({ loadFlaggedAccounts: async () => ({ version: 1, accounts: [{ email: "refresh@example.com", refreshToken: "refresh-token", addedAt: 1, lastUsed: 1 }] }), - lookupCodexCliTokensByEmail: async () => null, + lookupCodexCliTokensByEmail: async () => { throw new Error("busy"); }, queuedRefresh: async () => ({ type: "success", access: "new-access", refresh: "new-refresh", expires: Date.now() + 60_000 }), resolveAccountSelection: (tokens) => ({ refreshToken: tokens.refresh, accessToken: tokens.access }) as never, persistAccounts: vi.fn(async () => { calls.push("persistAccounts"); }), @@ -103,10 +103,10 @@ describe("verifyRuntimeFlaggedAccounts", () => { logInfo: vi.fn(), showLine: vi.fn(), }); - expect(calls).toEqual(["saveFlaggedAccounts", "persistAccounts"]); + expect(calls).toEqual(["persistAccounts", "saveFlaggedAccounts"]); }); - it("keeps flagged state saved when persistAccounts throws EBUSY", async () => { + it("leaves flagged state untouched when persistAccounts throws EBUSY", async () => { const saveFlaggedAccounts = vi.fn(async () => {}); const persistAccounts = vi.fn(async () => { const error = new Error("busy") as Error & { code?: string }; @@ -116,7 +116,7 @@ describe("verifyRuntimeFlaggedAccounts", () => { await expect( verifyRuntimeFlaggedAccounts({ loadFlaggedAccounts: async () => ({ version: 1, accounts: [{ email: "refresh@example.com", refreshToken: "refresh-token", addedAt: 1, lastUsed: 1 }] }), - lookupCodexCliTokensByEmail: async () => null, + lookupCodexCliTokensByEmail: async () => { throw new Error("busy"); }, queuedRefresh: async () => ({ type: "success", access: "new-access", refresh: "new-refresh", expires: Date.now() + 60_000 }), resolveAccountSelection: (tokens) => ({ refreshToken: tokens.refresh, accessToken: tokens.access }) as never, persistAccounts, @@ -126,6 +126,6 @@ describe("verifyRuntimeFlaggedAccounts", () => { showLine: vi.fn(), }), ).rejects.toThrow("busy"); - expect(saveFlaggedAccounts).toHaveBeenCalledTimes(1); + expect(saveFlaggedAccounts).not.toHaveBeenCalled(); }); }); From 8c4646c0493fdebefcdf92f31e972ae64681b280 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 05:54:35 +0800 Subject: [PATCH 240/376] refactor: extract runtime cache and event helpers --- index.ts | 146 ++++++++++----------------- lib/runtime/account-manager-cache.ts | 35 +++++++ lib/runtime/event-handler.ts | 76 ++++++++++++++ lib/runtime/quota-settings.ts | 43 ++++++++ test/account-manager-cache.test.ts | 43 ++++++++ test/account-pool.test.ts | 55 ++++++++++ test/event-handler.test.ts | 66 ++++++++++++ test/quota-settings.test.ts | 37 +++++++ 8 files changed, 410 insertions(+), 91 deletions(-) create mode 100644 lib/runtime/account-manager-cache.ts create mode 100644 lib/runtime/event-handler.ts create mode 100644 lib/runtime/quota-settings.ts create mode 100644 test/account-manager-cache.test.ts create mode 100644 test/account-pool.test.ts create mode 100644 test/event-handler.test.ts create mode 100644 test/quota-settings.test.ts diff --git a/index.ts b/index.ts index 325e0727..8ae86526 100644 --- a/index.ts +++ b/index.ts @@ -193,6 +193,10 @@ import { clampActiveIndices, isFlaggableFailure, } from "./lib/runtime/account-check-helpers.js"; +import { + invalidateAccountManagerCacheState, + reloadAccountManagerFromDiskState, +} from "./lib/runtime/account-manager-cache.js"; import { type TokenSuccessWithAccount as AccountPoolTokenSuccessWithAccount, persistAccountPoolResults, @@ -204,7 +208,12 @@ import { resolveActiveIndex, } from "./lib/runtime/account-status.js"; import { runBrowserOAuthFlow } from "./lib/runtime/browser-oauth-flow.js"; +import { handleRuntimeEvent } from "./lib/runtime/event-handler.js"; import { buildManualOAuthFlow } from "./lib/runtime/manual-oauth-flow.js"; +import { + applyPreemptiveQuotaSettingsFromConfig, + resolveUiRuntimeFromConfig, +} from "./lib/runtime/quota-settings.js"; import { ensureLiveAccountSyncState, ensureRefreshGuardianState, @@ -500,31 +509,33 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }; const resolveUiRuntime = (): UiRuntimeOptions => { - return applyUiRuntimeFromConfig(loadPluginConfig(), setUiRuntimeOptions); + return resolveUiRuntimeFromConfig(loadPluginConfig, (pluginConfig) => + applyUiRuntimeFromConfig(pluginConfig, setUiRuntimeOptions), + ); }; const invalidateAccountManagerCache = (): void => { - cachedAccountManager = null; - accountManagerPromise = null; + const next = invalidateAccountManagerCacheState(); + cachedAccountManager = next.cachedAccountManager; + accountManagerPromise = next.accountManagerPromise; }; const reloadAccountManagerFromDisk = async ( authFallback?: OAuthAuthDetails, ): Promise => { - if (accountReloadInFlight) { - return accountReloadInFlight; - } - accountReloadInFlight = (async () => { - const reloaded = await AccountManager.loadFromDisk(authFallback); - cachedAccountManager = reloaded; - accountManagerPromise = Promise.resolve(reloaded); - return reloaded; - })(); - try { - return await accountReloadInFlight; - } finally { - accountReloadInFlight = null; - } + accountReloadInFlight = reloadAccountManagerFromDiskState({ + currentReloadInFlight: accountReloadInFlight, + loadFromDisk: (fallback) => AccountManager.loadFromDisk(fallback), + authFallback, + onLoaded: (reloaded) => { + cachedAccountManager = reloaded; + accountManagerPromise = Promise.resolve(reloaded); + }, + onSettled: () => { + accountReloadInFlight = null; + }, + }); + return accountReloadInFlight; }; const applyAccountStorageScope = ( @@ -611,85 +622,38 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const applyPreemptiveQuotaSettings = ( pluginConfig: ReturnType, - ): void => { - preemptiveQuotaScheduler.configure({ - enabled: getPreemptiveQuotaEnabled(pluginConfig), - remainingPercentThresholdPrimary: - getPreemptiveQuotaRemainingPercent5h(pluginConfig), - remainingPercentThresholdSecondary: - getPreemptiveQuotaRemainingPercent7d(pluginConfig), - maxDeferralMs: getPreemptiveQuotaMaxDeferralMs(pluginConfig), + ): void => + applyPreemptiveQuotaSettingsFromConfig(pluginConfig, { + configure: (options) => preemptiveQuotaScheduler.configure(options), + getPreemptiveQuotaEnabled, + getPreemptiveQuotaRemainingPercent5h, + getPreemptiveQuotaRemainingPercent7d, + getPreemptiveQuotaMaxDeferralMs, }); - }; // Event handler for session recovery and account selection const eventHandler = async (input: { event: { type: string; properties?: unknown }; - }) => { - try { - const { event } = input; - // Handle TUI account selection events - // Accepts generic selection events with an index property - if ( - event.type === "account.select" || - event.type === "openai.account.select" - ) { - const props = event.properties as { - index?: number; - accountIndex?: number; - provider?: string; - }; - // Filter by provider if specified - if ( - props.provider && - props.provider !== "openai" && - props.provider !== PROVIDER_ID - ) { - return; - } - - const index = props.index ?? props.accountIndex; - if (typeof index === "number") { - const storage = await loadAccounts(); - if (!storage || index < 0 || index >= storage.accounts.length) { - return; - } - - const now = Date.now(); - const account = storage.accounts[index]; - if (account) { - account.lastUsed = now; - account.lastSwitchReason = "rotation"; - } - storage.activeIndex = index; - storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; - for (const family of MODEL_FAMILIES) { - storage.activeIndexByFamily[family] = index; - } - - await saveAccounts(storage); - if (cachedAccountManager) { - await cachedAccountManager.syncCodexCliActiveSelectionForIndex( - index, - ); - } - lastCodexCliActiveSyncIndex = index; - - // Reload manager from disk so we don't overwrite newer rotated - // refresh tokens with stale in-memory state. - if (cachedAccountManager) { - await reloadAccountManagerFromDisk(); - } - - await showToast(`Switched to account ${index + 1}`, "info"); - } - } - } catch (error) { - logDebug( - `[${PLUGIN_NAME}] Event handler error: ${error instanceof Error ? error.message : String(error)}`, - ); - } - }; + }) => + handleRuntimeEvent({ + input, + providerId: PROVIDER_ID, + modelFamilies: MODEL_FAMILIES, + loadAccounts, + saveAccounts, + hasCachedAccountManager: () => !!cachedAccountManager, + syncCodexCliActiveSelectionForIndex: async (index) => { + if (!cachedAccountManager) return; + await cachedAccountManager.syncCodexCliActiveSelectionForIndex(index); + }, + setLastCodexCliActiveSyncIndex: (index) => { + lastCodexCliActiveSyncIndex = index; + }, + reloadAccountManagerFromDisk: () => reloadAccountManagerFromDisk(), + showToast: (message, variant) => showToast(message, variant), + logDebug, + pluginName: PLUGIN_NAME, + }); // Initialize runtime UI settings once on plugin load; auth/tools refresh this dynamically. resolveUiRuntime(); diff --git a/lib/runtime/account-manager-cache.ts b/lib/runtime/account-manager-cache.ts new file mode 100644 index 00000000..1505cf9f --- /dev/null +++ b/lib/runtime/account-manager-cache.ts @@ -0,0 +1,35 @@ +import type { OAuthAuthDetails } from "../types.js"; + +export function invalidateAccountManagerCacheState(): { + cachedAccountManager: null; + accountManagerPromise: null; +} { + return { + cachedAccountManager: null, + accountManagerPromise: null, + }; +} + +export async function reloadAccountManagerFromDiskState(params: { + currentReloadInFlight: Promise | null; + loadFromDisk: (authFallback?: OAuthAuthDetails) => Promise; + authFallback?: OAuthAuthDetails; + onLoaded: (manager: TManager) => void; + onSettled: () => void; +}): Promise { + if (params.currentReloadInFlight) { + return params.currentReloadInFlight; + } + + const inFlight = (async () => { + const reloaded = await params.loadFromDisk(params.authFallback); + params.onLoaded(reloaded); + return reloaded; + })(); + + try { + return await inFlight; + } finally { + params.onSettled(); + } +} diff --git a/lib/runtime/event-handler.ts b/lib/runtime/event-handler.ts new file mode 100644 index 00000000..23719880 --- /dev/null +++ b/lib/runtime/event-handler.ts @@ -0,0 +1,76 @@ +import type { ModelFamily } from "../prompts/codex.js"; +import type { AccountStorageV3 } from "../storage.js"; + +export async function handleRuntimeEvent(params: { + input: { event: { type: string; properties?: unknown } }; + providerId: string; + modelFamilies: readonly ModelFamily[]; + loadAccounts: () => Promise; + saveAccounts: (storage: AccountStorageV3) => Promise; + hasCachedAccountManager: () => boolean; + syncCodexCliActiveSelectionForIndex: (index: number) => Promise; + setLastCodexCliActiveSyncIndex: (index: number) => void; + reloadAccountManagerFromDisk: () => Promise; + showToast: (message: string, variant: "info") => Promise; + logDebug: (message: string) => void; + pluginName: string; +}): Promise { + try { + const { event } = params.input; + if ( + event.type !== "account.select" && + event.type !== "openai.account.select" + ) { + return; + } + + const props = event.properties as { + index?: number; + accountIndex?: number; + provider?: string; + }; + if ( + props.provider && + props.provider !== "openai" && + props.provider !== params.providerId + ) { + return; + } + + const index = props.index ?? props.accountIndex; + if (typeof index !== "number") return; + + const storage = await params.loadAccounts(); + if (!storage || index < 0 || index >= storage.accounts.length) { + return; + } + + const now = Date.now(); + const account = storage.accounts[index]; + if (account) { + account.lastUsed = now; + account.lastSwitchReason = "rotation"; + } + storage.activeIndex = index; + storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; + for (const family of params.modelFamilies) { + storage.activeIndexByFamily[family] = index; + } + + await params.saveAccounts(storage); + if (params.hasCachedAccountManager()) { + await params.syncCodexCliActiveSelectionForIndex(index); + } + params.setLastCodexCliActiveSyncIndex(index); + + if (params.hasCachedAccountManager()) { + await params.reloadAccountManagerFromDisk(); + } + + await params.showToast(`Switched to account ${index + 1}`, "info"); + } catch (error) { + params.logDebug( + `[${params.pluginName}] Event handler error: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/lib/runtime/quota-settings.ts b/lib/runtime/quota-settings.ts new file mode 100644 index 00000000..4254f09b --- /dev/null +++ b/lib/runtime/quota-settings.ts @@ -0,0 +1,43 @@ +export function applyPreemptiveQuotaSettingsFromConfig( + pluginConfig: ReturnType, + deps: { + configure: (options: { + enabled: boolean; + remainingPercentThresholdPrimary: number; + remainingPercentThresholdSecondary: number; + maxDeferralMs: number; + }) => void; + getPreemptiveQuotaEnabled: ( + config: ReturnType, + ) => boolean; + getPreemptiveQuotaRemainingPercent5h: ( + config: ReturnType, + ) => number; + getPreemptiveQuotaRemainingPercent7d: ( + config: ReturnType, + ) => number; + getPreemptiveQuotaMaxDeferralMs: ( + config: ReturnType, + ) => number; + }, +): void { + deps.configure({ + enabled: deps.getPreemptiveQuotaEnabled(pluginConfig), + remainingPercentThresholdPrimary: + deps.getPreemptiveQuotaRemainingPercent5h(pluginConfig), + remainingPercentThresholdSecondary: + deps.getPreemptiveQuotaRemainingPercent7d(pluginConfig), + maxDeferralMs: deps.getPreemptiveQuotaMaxDeferralMs(pluginConfig), + }); +} + +export function resolveUiRuntimeFromConfig( + loadPluginConfig: () => ReturnType< + typeof import("../config.js").loadPluginConfig + >, + applyUiRuntimeFromConfig: ( + pluginConfig: ReturnType, + ) => ReturnType, +): ReturnType { + return applyUiRuntimeFromConfig(loadPluginConfig()); +} diff --git a/test/account-manager-cache.test.ts b/test/account-manager-cache.test.ts new file mode 100644 index 00000000..5220bb5b --- /dev/null +++ b/test/account-manager-cache.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vitest"; +import { + invalidateAccountManagerCacheState, + reloadAccountManagerFromDiskState, +} from "../lib/runtime/account-manager-cache.js"; + +describe("account manager cache helpers", () => { + it("invalidates cache state", () => { + expect(invalidateAccountManagerCacheState()).toEqual({ + cachedAccountManager: null, + accountManagerPromise: null, + }); + }); + + it("reuses in-flight reload and updates callbacks on success", async () => { + const onLoaded = vi.fn(); + const onSettled = vi.fn(); + const inFlight = Promise.resolve({ id: 1 }); + await expect( + reloadAccountManagerFromDiskState({ + currentReloadInFlight: inFlight, + loadFromDisk: vi.fn(), + onLoaded, + onSettled, + }), + ).resolves.toEqual({ id: 1 }); + + expect(onLoaded).not.toHaveBeenCalled(); + expect(onSettled).not.toHaveBeenCalled(); + + const loadFromDisk = vi.fn(async () => ({ id: 2 })); + await expect( + reloadAccountManagerFromDiskState({ + currentReloadInFlight: null, + loadFromDisk, + onLoaded, + onSettled, + }), + ).resolves.toEqual({ id: 2 }); + expect(onLoaded).toHaveBeenCalledWith({ id: 2 }); + expect(onSettled).toHaveBeenCalled(); + }); +}); diff --git a/test/account-pool.test.ts b/test/account-pool.test.ts new file mode 100644 index 00000000..e59a10b1 --- /dev/null +++ b/test/account-pool.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest"; +import { persistAccountPoolResults } from "../lib/runtime/account-pool.js"; + +describe("account pool helper", () => { + it("persists new account results into storage transaction", async () => { + const persist = vi.fn(async () => undefined); + + await persistAccountPoolResults({ + results: [ + { + type: "success", + access: "access-token", + refresh: "refresh-token", + expires: 123, + accountIdOverride: "acct_1", + accountIdSource: "manual", + accountLabel: "Primary", + workspaces: [ + { id: "acct_1", name: "Primary", enabled: true, isDefault: true }, + ], + }, + ], + replaceAll: false, + modelFamilies: ["codex"], + withAccountStorageTransaction: async (handler) => handler(null, persist), + findMatchingAccountIndex: () => undefined, + extractAccountId: () => undefined, + extractAccountEmail: () => "user@example.com", + sanitizeEmail: (email) => email, + }); + + expect(persist).toHaveBeenCalledWith({ + version: 3, + accounts: [ + { + accountId: "acct_1", + accountIdSource: "manual", + accountLabel: "Primary", + email: "user@example.com", + refreshToken: "refresh-token", + accessToken: "access-token", + expiresAt: 123, + addedAt: expect.any(Number), + lastUsed: expect.any(Number), + workspaces: [ + { id: "acct_1", name: "Primary", enabled: true, isDefault: true }, + ], + currentWorkspaceIndex: 0, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }); + }); +}); diff --git a/test/event-handler.test.ts b/test/event-handler.test.ts new file mode 100644 index 00000000..818a612a --- /dev/null +++ b/test/event-handler.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest"; +import { handleRuntimeEvent } from "../lib/runtime/event-handler.js"; + +describe("runtime event handler", () => { + it("ignores unrelated providers and out-of-range selections", async () => { + const loadAccounts = vi.fn(async () => ({ + accounts: [{ refreshToken: "a" }], + activeIndex: 0, + activeIndexByFamily: {}, + })); + + await handleRuntimeEvent({ + input: { + event: { + type: "account.select", + properties: { provider: "other", index: 0 }, + }, + }, + providerId: "openai", + modelFamilies: ["codex"], + loadAccounts, + saveAccounts: vi.fn(), + hasCachedAccountManager: () => false, + syncCodexCliActiveSelectionForIndex: vi.fn(), + setLastCodexCliActiveSyncIndex: vi.fn(), + reloadAccountManagerFromDisk: vi.fn(), + showToast: vi.fn(), + logDebug: vi.fn(), + pluginName: "plugin", + }); + + expect(loadAccounts).not.toHaveBeenCalled(); + }); + + it("updates storage and syncs active selection for valid account events", async () => { + const storage = { + accounts: [{ refreshToken: "a" }], + activeIndex: 0, + activeIndexByFamily: {}, + }; + const saveAccounts = vi.fn(async () => undefined); + const sync = vi.fn(async () => undefined); + const reload = vi.fn(async () => undefined); + const showToast = vi.fn(async () => undefined); + + await handleRuntimeEvent({ + input: { event: { type: "account.select", properties: { index: 0 } } }, + providerId: "openai", + modelFamilies: ["codex"], + loadAccounts: async () => storage as never, + saveAccounts, + hasCachedAccountManager: () => true, + syncCodexCliActiveSelectionForIndex: sync, + setLastCodexCliActiveSyncIndex: vi.fn(), + reloadAccountManagerFromDisk: reload, + showToast, + logDebug: vi.fn(), + pluginName: "plugin", + }); + + expect(saveAccounts).toHaveBeenCalled(); + expect(sync).toHaveBeenCalledWith(0); + expect(reload).toHaveBeenCalled(); + expect(showToast).toHaveBeenCalledWith("Switched to account 1", "info"); + }); +}); diff --git a/test/quota-settings.test.ts b/test/quota-settings.test.ts new file mode 100644 index 00000000..eeacb3aa --- /dev/null +++ b/test/quota-settings.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; +import { + applyPreemptiveQuotaSettingsFromConfig, + resolveUiRuntimeFromConfig, +} from "../lib/runtime/quota-settings.js"; + +describe("quota settings helpers", () => { + it("applies preemptive quota config via dependency getters", () => { + const configure = vi.fn(); + applyPreemptiveQuotaSettingsFromConfig({} as never, { + configure, + getPreemptiveQuotaEnabled: () => true, + getPreemptiveQuotaRemainingPercent5h: () => 10, + getPreemptiveQuotaRemainingPercent7d: () => 20, + getPreemptiveQuotaMaxDeferralMs: () => 3000, + }); + + expect(configure).toHaveBeenCalledWith({ + enabled: true, + remainingPercentThresholdPrimary: 10, + remainingPercentThresholdSecondary: 20, + maxDeferralMs: 3000, + }); + }); + + it("resolves ui runtime through provided loaders", () => { + const loadPluginConfig = vi.fn(() => ({ a: 1 })); + const applyUiRuntime = vi.fn(() => ({ theme: {} })); + const result = resolveUiRuntimeFromConfig( + loadPluginConfig as never, + applyUiRuntime as never, + ); + expect(loadPluginConfig).toHaveBeenCalled(); + expect(applyUiRuntime).toHaveBeenCalledWith({ a: 1 }); + expect(result).toEqual({ theme: {} }); + }); +}); From 8fe9b82cbea8c4e604641a4bd378d5552bf9e817 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 05:57:33 +0800 Subject: [PATCH 241/376] refactor: extract settings hub entry wrappers --- lib/codex-manager/backend-settings-prompt.ts | 202 ++++++++++++++++++ lib/codex-manager/dashboard-settings-entry.ts | 57 +++++ lib/codex-manager/settings-hub.ts | 192 ++++------------- test/backend-settings-prompt.test.ts | 34 +++ test/dashboard-settings-entry.test.ts | 26 +++ 5 files changed, 359 insertions(+), 152 deletions(-) create mode 100644 lib/codex-manager/backend-settings-prompt.ts create mode 100644 lib/codex-manager/dashboard-settings-entry.ts create mode 100644 test/backend-settings-prompt.test.ts create mode 100644 test/dashboard-settings-entry.test.ts diff --git a/lib/codex-manager/backend-settings-prompt.ts b/lib/codex-manager/backend-settings-prompt.ts new file mode 100644 index 00000000..4fa0068a --- /dev/null +++ b/lib/codex-manager/backend-settings-prompt.ts @@ -0,0 +1,202 @@ +import type { PluginConfig } from "../types.js"; +import type { UiRuntimeOptions } from "../ui/runtime.js"; +import type { MenuItem } from "../ui/select.js"; +import type { + BackendCategoryKey, + BackendCategoryOption, + BackendSettingFocusKey, + BackendSettingsHubAction, +} from "./backend-settings-schema.js"; + +export async function promptBackendSettingsMenu(params: { + initial: PluginConfig; + isInteractive: () => boolean; + ui: UiRuntimeOptions; + cloneBackendPluginConfig: (config: PluginConfig) => PluginConfig; + backendCategoryOptions: readonly BackendCategoryOption[]; + getBackendCategoryInitialFocus: ( + category: BackendCategoryOption, + ) => BackendSettingFocusKey; + buildBackendSettingsPreview: ( + config: PluginConfig, + ui: UiRuntimeOptions, + focus: BackendSettingFocusKey, + deps: { + highlightPreviewToken: (text: string, ui: UiRuntimeOptions) => string; + }, + ) => { label: string; hint: string }; + highlightPreviewToken: (text: string, ui: UiRuntimeOptions) => string; + select: ( + items: MenuItem[], + options: { + message: string; + subtitle: string; + help: string; + clearScreen: boolean; + theme: UiRuntimeOptions["theme"]; + selectedEmphasis: "minimal"; + initialCursor?: number; + onCursorChange: (event: { cursor: number }) => void; + onInput: (raw: string) => T | undefined; + }, + ) => Promise; + getBackendCategory: ( + key: BackendCategoryKey, + categories: readonly BackendCategoryOption[], + ) => BackendCategoryOption | null; + promptBackendCategorySettings: ( + initial: PluginConfig, + category: BackendCategoryOption, + focus: BackendSettingFocusKey, + ) => Promise<{ draft: PluginConfig; focusKey: BackendSettingFocusKey }>; + backendDefaults: PluginConfig; + copy: { + previewHeading: string; + backendCategoriesHeading: string; + resetDefault: string; + saveAndBack: string; + backNoSave: string; + backendTitle: string; + backendSubtitle: string; + backendHelp: string; + back: string; + }; +}): Promise { + if (!params.isInteractive()) return null; + + let draft = params.cloneBackendPluginConfig(params.initial); + let activeCategory = params.backendCategoryOptions[0]?.key ?? "session-sync"; + const focusByCategory: Partial< + Record + > = {}; + for (const category of params.backendCategoryOptions) { + focusByCategory[category.key] = + params.getBackendCategoryInitialFocus(category); + } + + while (true) { + const previewFocus = focusByCategory[activeCategory] ?? null; + const preview = params.buildBackendSettingsPreview( + draft, + params.ui, + previewFocus, + { + highlightPreviewToken: params.highlightPreviewToken, + }, + ); + const categoryItems: MenuItem[] = + params.backendCategoryOptions.map((category, index) => ({ + label: `${index + 1}. ${category.label}`, + hint: category.description, + value: { type: "open-category", key: category.key }, + color: "green", + })); + + const items: MenuItem[] = [ + { + label: params.copy.previewHeading, + value: { type: "cancel" }, + kind: "heading", + }, + { + label: preview.label, + hint: preview.hint, + value: { type: "cancel" }, + disabled: true, + color: "green", + hideUnavailableSuffix: true, + }, + { label: "", value: { type: "cancel" }, separator: true }, + { + label: params.copy.backendCategoriesHeading, + value: { type: "cancel" }, + kind: "heading", + }, + ...categoryItems, + { label: "", value: { type: "cancel" }, separator: true }, + { + label: params.copy.resetDefault, + value: { type: "reset" }, + color: "yellow", + }, + { + label: params.copy.saveAndBack, + value: { type: "save" }, + color: "green", + }, + { + label: params.copy.backNoSave, + value: { type: "cancel" }, + color: "red", + }, + ]; + + const initialCursor = items.findIndex((item) => { + if (item.separator || item.disabled || item.kind === "heading") + return false; + return ( + item.value.type === "open-category" && item.value.key === activeCategory + ); + }); + + const result = await params.select(items, { + message: params.copy.backendTitle, + subtitle: params.copy.backendSubtitle, + help: params.copy.backendHelp, + clearScreen: true, + theme: params.ui.theme, + selectedEmphasis: "minimal", + initialCursor: initialCursor >= 0 ? initialCursor : undefined, + onCursorChange: ({ cursor }) => { + const focusedItem = items[cursor]; + if (focusedItem?.value.type === "open-category") { + activeCategory = focusedItem.value.key; + } + }, + onInput: (raw) => { + const lower = raw.toLowerCase(); + if (lower === "q") return { type: "cancel" as const }; + if (lower === "s") return { type: "save" as const }; + if (lower === "r") return { type: "reset" as const }; + const parsed = Number.parseInt(raw, 10); + if ( + Number.isFinite(parsed) && + parsed >= 1 && + parsed <= params.backendCategoryOptions.length + ) { + const target = params.backendCategoryOptions[parsed - 1]; + if (target) + return { type: "open-category" as const, key: target.key }; + } + return undefined; + }, + }); + + if (!result || result.type === "cancel") return null; + if (result.type === "save") return draft; + if (result.type === "reset") { + draft = params.cloneBackendPluginConfig(params.backendDefaults); + for (const category of params.backendCategoryOptions) { + focusByCategory[category.key] = + params.getBackendCategoryInitialFocus(category); + } + activeCategory = params.backendCategoryOptions[0]?.key ?? activeCategory; + continue; + } + + const category = params.getBackendCategory( + result.key, + params.backendCategoryOptions, + ); + if (!category) continue; + activeCategory = category.key; + const categoryResult = await params.promptBackendCategorySettings( + draft, + category, + focusByCategory[category.key] ?? + params.getBackendCategoryInitialFocus(category), + ); + draft = categoryResult.draft; + focusByCategory[category.key] = categoryResult.focusKey; + } +} diff --git a/lib/codex-manager/dashboard-settings-entry.ts b/lib/codex-manager/dashboard-settings-entry.ts new file mode 100644 index 00000000..b79781d2 --- /dev/null +++ b/lib/codex-manager/dashboard-settings-entry.ts @@ -0,0 +1,57 @@ +import type { DashboardDisplaySettings } from "../dashboard-settings.js"; + +export async function configureDashboardSettingsEntry( + currentSettings: DashboardDisplaySettings | undefined, + deps: { + configureDashboardSettingsController: ( + currentSettings: DashboardDisplaySettings | undefined, + deps: { + loadDashboardDisplaySettings: () => Promise; + promptSettings: ( + settings: DashboardDisplaySettings, + ) => Promise; + settingsEqual: ( + left: DashboardDisplaySettings, + right: DashboardDisplaySettings, + ) => boolean; + persistSelection: ( + selected: DashboardDisplaySettings, + ) => Promise; + applyUiThemeFromDashboardSettings: ( + settings: DashboardDisplaySettings, + ) => void; + isInteractive: () => boolean; + getDashboardSettingsPath: () => string; + writeLine: (message: string) => void; + }, + ) => Promise; + loadDashboardDisplaySettings: () => Promise; + promptSettings: ( + settings: DashboardDisplaySettings, + ) => Promise; + settingsEqual: ( + left: DashboardDisplaySettings, + right: DashboardDisplaySettings, + ) => boolean; + persistSelection: ( + selected: DashboardDisplaySettings, + ) => Promise; + applyUiThemeFromDashboardSettings: ( + settings: DashboardDisplaySettings, + ) => void; + isInteractive: () => boolean; + getDashboardSettingsPath: () => string; + writeLine: (message: string) => void; + }, +): Promise { + return deps.configureDashboardSettingsController(currentSettings, { + loadDashboardDisplaySettings: deps.loadDashboardDisplaySettings, + promptSettings: deps.promptSettings, + settingsEqual: deps.settingsEqual, + persistSelection: deps.persistSelection, + applyUiThemeFromDashboardSettings: deps.applyUiThemeFromDashboardSettings, + isInteractive: deps.isInteractive, + getDashboardSettingsPath: deps.getDashboardSettingsPath, + writeLine: deps.writeLine, + }); +} diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index dbfface6..24ef9614 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -20,7 +20,7 @@ import { loadAccounts, normalizeAccountStorage } from "../storage.js"; import type { PluginConfig } from "../types.js"; import { UI_COPY } from "../ui/copy.js"; import { getUiRuntimeOptions, setUiRuntimeOptions } from "../ui/runtime.js"; -import { type MenuItem, select } from "../ui/select.js"; +import { select } from "../ui/select.js"; import { sleep } from "../utils.js"; import { applyBackendCategoryDefaults, @@ -39,16 +39,15 @@ import { cloneBackendPluginConfig, formatBackendNumberValue, } from "./backend-settings-helpers.js"; +import { promptBackendSettingsMenu } from "./backend-settings-prompt.js"; import { BACKEND_CATEGORY_OPTIONS, BACKEND_DEFAULTS, BACKEND_NUMBER_OPTION_BY_KEY, BACKEND_TOGGLE_OPTION_BY_KEY, - type BackendCategoryKey, type BackendCategoryOption, type BackendNumberSettingOption, type BackendSettingFocusKey, - type BackendSettingsHubAction, } from "./backend-settings-schema.js"; import { promptBehaviorSettingsPanel } from "./behavior-settings-panel.js"; import { promptDashboardDisplayPanel } from "./dashboard-display-panel.js"; @@ -63,6 +62,7 @@ import { cloneDashboardSettingsData, dashboardSettingsDataEqual, } from "./dashboard-settings-data.js"; +import { configureDashboardSettingsEntry } from "./dashboard-settings-entry.js"; import { promptExperimentalSettingsMenu } from "./experimental-settings-prompt.js"; import { getExperimentalSelectOptions, @@ -475,10 +475,29 @@ async function promptDashboardDisplaySettings( }); } +function reorderField( + fields: DashboardStatuslineField[], + key: DashboardStatuslineField, + direction: -1 | 1, +): DashboardStatuslineField[] { + const index = fields.indexOf(key); + if (index < 0) return fields; + const target = index + direction; + if (target < 0 || target >= fields.length) return fields; + const next = [...fields]; + const current = next[index]; + const swap = next[target]; + if (!current || !swap) return fields; + next[index] = swap; + next[target] = current; + return next; +} + async function configureDashboardDisplaySettings( currentSettings?: DashboardDisplaySettings, ): Promise { - return configureDashboardSettingsController(currentSettings, { + return configureDashboardSettingsEntry(currentSettings, { + configureDashboardSettingsController, loadDashboardDisplaySettings, promptSettings: promptDashboardDisplaySettings, settingsEqual: dashboardSettingsEqual, @@ -497,24 +516,6 @@ async function configureDashboardDisplaySettings( }); } -function reorderField( - fields: DashboardStatuslineField[], - key: DashboardStatuslineField, - direction: -1 | 1, -): DashboardStatuslineField[] { - const index = fields.indexOf(key); - if (index < 0) return fields; - const target = index + direction; - if (target < 0 || target >= fields.length) return fields; - const next = [...fields]; - const current = next[index]; - const swap = next[target]; - if (!current || !swap) return fields; - next[index] = swap; - next[target] = current; - return next; -} - async function promptStatuslineSettings( initial: DashboardDisplaySettings, ): Promise { @@ -534,7 +535,8 @@ async function promptStatuslineSettings( async function configureStatuslineSettings( currentSettings?: DashboardDisplaySettings, ): Promise { - return configureDashboardSettingsController(currentSettings, { + return configureDashboardSettingsEntry(currentSettings, { + configureDashboardSettingsController, loadDashboardDisplaySettings, promptSettings: promptStatuslineSettings, settingsEqual: dashboardSettingsEqual, @@ -622,135 +624,21 @@ async function promptBackendCategorySettings( async function promptBackendSettings( initial: PluginConfig, ): Promise { - if (!input.isTTY || !output.isTTY) return null; - - const ui = getUiRuntimeOptions(); - let draft = cloneBackendPluginConfig(initial); - let activeCategory = BACKEND_CATEGORY_OPTIONS[0]?.key ?? "session-sync"; - const focusByCategory: Partial< - Record - > = {}; - for (const category of BACKEND_CATEGORY_OPTIONS) { - focusByCategory[category.key] = getBackendCategoryInitialFocus(category); - } - - while (true) { - const previewFocus = focusByCategory[activeCategory] ?? null; - const preview = buildBackendSettingsPreview(draft, ui, previewFocus, { - highlightPreviewToken, - }); - const categoryItems: MenuItem[] = - BACKEND_CATEGORY_OPTIONS.map((category, index) => { - return { - label: `${index + 1}. ${category.label}`, - hint: category.description, - value: { type: "open-category", key: category.key }, - color: "green", - }; - }); - - const items: MenuItem[] = [ - { - label: UI_COPY.settings.previewHeading, - value: { type: "cancel" }, - kind: "heading", - }, - { - label: preview.label, - hint: preview.hint, - value: { type: "cancel" }, - disabled: true, - color: "green", - hideUnavailableSuffix: true, - }, - { label: "", value: { type: "cancel" }, separator: true }, - { - label: UI_COPY.settings.backendCategoriesHeading, - value: { type: "cancel" }, - kind: "heading", - }, - ...categoryItems, - { label: "", value: { type: "cancel" }, separator: true }, - { - label: UI_COPY.settings.resetDefault, - value: { type: "reset" }, - color: "yellow", - }, - { - label: UI_COPY.settings.saveAndBack, - value: { type: "save" }, - color: "green", - }, - { - label: UI_COPY.settings.backNoSave, - value: { type: "cancel" }, - color: "red", - }, - ]; - - const initialCursor = items.findIndex((item) => { - if (item.separator || item.disabled || item.kind === "heading") - return false; - return ( - item.value.type === "open-category" && item.value.key === activeCategory - ); - }); - - const result = await select(items, { - message: UI_COPY.settings.backendTitle, - subtitle: UI_COPY.settings.backendSubtitle, - help: UI_COPY.settings.backendHelp, - clearScreen: true, - theme: ui.theme, - selectedEmphasis: "minimal", - initialCursor: initialCursor >= 0 ? initialCursor : undefined, - onCursorChange: ({ cursor }) => { - const focusedItem = items[cursor]; - if (focusedItem?.value.type === "open-category") { - activeCategory = focusedItem.value.key; - } - }, - onInput: (raw) => { - const lower = raw.toLowerCase(); - if (lower === "q") return { type: "cancel" }; - if (lower === "s") return { type: "save" }; - if (lower === "r") return { type: "reset" }; - const parsed = Number.parseInt(raw, 10); - if ( - Number.isFinite(parsed) && - parsed >= 1 && - parsed <= BACKEND_CATEGORY_OPTIONS.length - ) { - const target = BACKEND_CATEGORY_OPTIONS[parsed - 1]; - if (target) return { type: "open-category", key: target.key }; - } - return undefined; - }, - }); - - if (!result || result.type === "cancel") return null; - if (result.type === "save") return draft; - if (result.type === "reset") { - draft = cloneBackendPluginConfig(BACKEND_DEFAULTS); - for (const category of BACKEND_CATEGORY_OPTIONS) { - focusByCategory[category.key] = - getBackendCategoryInitialFocus(category); - } - activeCategory = BACKEND_CATEGORY_OPTIONS[0]?.key ?? activeCategory; - continue; - } - - const category = getBackendCategory(result.key, BACKEND_CATEGORY_OPTIONS); - if (!category) continue; - activeCategory = category.key; - const categoryResult = await promptBackendCategorySettings( - draft, - category, - focusByCategory[category.key] ?? getBackendCategoryInitialFocus(category), - ); - draft = categoryResult.draft; - focusByCategory[category.key] = categoryResult.focusKey; - } + return promptBackendSettingsMenu({ + initial, + isInteractive: () => input.isTTY && output.isTTY, + ui: getUiRuntimeOptions(), + cloneBackendPluginConfig, + backendCategoryOptions: BACKEND_CATEGORY_OPTIONS, + getBackendCategoryInitialFocus, + buildBackendSettingsPreview, + highlightPreviewToken, + select, + getBackendCategory, + promptBackendCategorySettings, + backendDefaults: BACKEND_DEFAULTS, + copy: UI_COPY.settings, + }); } async function loadExperimentalSyncTarget(): Promise< diff --git a/test/backend-settings-prompt.test.ts b/test/backend-settings-prompt.test.ts new file mode 100644 index 00000000..635575bf --- /dev/null +++ b/test/backend-settings-prompt.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from "vitest"; +import { promptBackendSettingsMenu } from "../lib/codex-manager/backend-settings-prompt.js"; + +describe("backend settings prompt", () => { + it("returns null when not interactive", async () => { + const result = await promptBackendSettingsMenu({ + initial: { fetchTimeoutMs: 1000 }, + isInteractive: () => false, + ui: { theme: {} } as never, + cloneBackendPluginConfig: (config) => ({ ...config }), + backendCategoryOptions: [], + getBackendCategoryInitialFocus: vi.fn(), + buildBackendSettingsPreview: vi.fn(), + highlightPreviewToken: vi.fn((text) => text), + select: vi.fn(), + getBackendCategory: vi.fn(), + promptBackendCategorySettings: vi.fn(), + backendDefaults: { fetchTimeoutMs: 1000 }, + copy: { + previewHeading: "Preview", + backendCategoriesHeading: "Categories", + resetDefault: "Reset", + saveAndBack: "Save", + backNoSave: "Back", + backendTitle: "Backend", + backendSubtitle: "Subtitle", + backendHelp: "Help", + back: "Back", + }, + }); + + expect(result).toBeNull(); + }); +}); diff --git a/test/dashboard-settings-entry.test.ts b/test/dashboard-settings-entry.test.ts new file mode 100644 index 00000000..6e3d7b0d --- /dev/null +++ b/test/dashboard-settings-entry.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from "vitest"; +import { configureDashboardSettingsEntry } from "../lib/codex-manager/dashboard-settings-entry.js"; + +describe("dashboard settings entry", () => { + it("delegates to dashboard settings controller with provided deps", async () => { + const configureDashboardSettingsController = vi.fn(async () => ({ + menuShowStatusBadge: false, + })); + const result = await configureDashboardSettingsEntry(undefined, { + configureDashboardSettingsController, + loadDashboardDisplaySettings: vi.fn(async () => ({ + menuShowStatusBadge: true, + })), + promptSettings: vi.fn(), + settingsEqual: vi.fn(() => false), + persistSelection: vi.fn(), + applyUiThemeFromDashboardSettings: vi.fn(), + isInteractive: vi.fn(() => true), + getDashboardSettingsPath: vi.fn(() => "/tmp/settings.json"), + writeLine: vi.fn(), + }); + + expect(configureDashboardSettingsController).toHaveBeenCalled(); + expect(result).toEqual({ menuShowStatusBadge: false }); + }); +}); From c1b0ddf2565f5b3316ac779e6ec40f1d94b2d580 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 05:59:29 +0800 Subject: [PATCH 242/376] refactor: extract storage save and port helpers --- lib/storage.ts | 368 +++++++++++++++--------------------- lib/storage/account-port.ts | 137 ++++++++++++++ lib/storage/account-save.ts | 91 +++++++++ lib/storage/gitignore.ts | 46 +++++ test/account-port.test.ts | 86 +++++++++ test/gitignore.test.ts | 69 +++++++ 6 files changed, 582 insertions(+), 215 deletions(-) create mode 100644 lib/storage/account-port.ts create mode 100644 lib/storage/account-save.ts create mode 100644 lib/storage/gitignore.ts create mode 100644 test/account-port.test.ts create mode 100644 test/gitignore.test.ts diff --git a/lib/storage.ts b/lib/storage.ts index f7986b9d..1e0b976c 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -12,6 +12,13 @@ import { import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; import { clearAccountStorageArtifacts } from "./storage/account-clear.js"; import { cloneAccountStorageForPersistence } from "./storage/account-persistence.js"; +import { + clearFlaggedAccountsEntry, + exportAccountsSnapshot, + importAccountsSnapshot, + saveFlaggedAccountsEntry, +} from "./storage/account-port.js"; +import { saveAccountsToDisk } from "./storage/account-save.js"; import { buildBackupMetadata } from "./storage/backup-metadata-builder.js"; import { ACCOUNTS_BACKUP_SUFFIX, @@ -30,6 +37,7 @@ import { loadFlaggedAccountsState, saveFlaggedAccountsUnlockedToDisk, } from "./storage/flagged-storage-io.js"; +import { ensureCodexGitignoreEntry } from "./storage/gitignore.js"; import { exportAccountsToFile, mergeImportedAccounts, @@ -220,40 +228,16 @@ type AccountLike = { async function ensureGitignore(storagePath: string): Promise { const state = getStoragePathState(); if (!state.currentStoragePath) return; - - const configDir = dirname(storagePath); - const inferredProjectRoot = dirname(configDir); - const candidateRoots = [state.currentProjectRoot, inferredProjectRoot].filter( - (root): root is string => typeof root === "string" && root.length > 0, - ); - const projectRoot = candidateRoots.find((root) => - existsSync(join(root, ".git")), - ); - if (!projectRoot) return; - const gitignorePath = join(projectRoot, ".gitignore"); - - try { - let content = ""; - if (existsSync(gitignorePath)) { - content = await fs.readFile(gitignorePath, "utf-8"); - const lines = content.split("\n").map((l) => l.trim()); - if ( - lines.includes(".codex") || - lines.includes(".codex/") || - lines.includes("/.codex") || - lines.includes("/.codex/") - ) { - return; - } - } - - const newContent = - content.endsWith("\n") || content === "" ? content : content + "\n"; - await fs.writeFile(gitignorePath, newContent + ".codex/\n", "utf-8"); - log.debug("Added .codex to .gitignore", { path: gitignorePath }); - } catch (error) { - log.warn("Failed to update .gitignore", { error: String(error) }); - } + await ensureCodexGitignoreEntry({ + storagePath, + currentProjectRoot: state.currentProjectRoot, + logDebug: (message, details) => { + log.debug(message, details); + }, + logWarn: (message, details) => { + log.warn(message, details); + }, + }); } type StoragePathState = { @@ -1688,139 +1672,117 @@ async function loadAccountsInternal( async function saveAccountsUnlocked(storage: AccountStorageV3): Promise { const path = getStoragePath(); const resetMarkerPath = getIntentionalResetMarkerPath(path); - const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; - const tempPath = `${path}.${uniqueSuffix}.tmp`; const walPath = getAccountsWalPath(path); - - try { - await fs.mkdir(dirname(path), { recursive: true }); - await ensureGitignore(path); - - if (looksLikeSyntheticFixtureStorage(storage)) { - try { - const existing = await loadNormalizedStorageFromPath( - path, - "existing account storage", - { - loadAccountsFromPath: (path) => - loadAccountsFromPath(path, { - normalizeAccountStorage, - isRecord, - }), - logWarn: (message, details) => { - log.warn(message, details); - }, - }, - ); - if ( - existing && - existing.accounts.length > 0 && - !looksLikeSyntheticFixtureStorage(existing) - ) { - throw new StorageError( - "Refusing to overwrite non-synthetic account storage with synthetic fixture payload", - "EINVALID", - path, - "Detected synthetic fixture-like account payload. Use explicit account import/login commands instead.", - ); - } - } catch (error) { - if (error instanceof StorageError) { - throw error; + return saveAccountsToDisk(storage, { + path, + resetMarkerPath, + walPath, + storageBackupEnabled: storageBackupEnabled && existsSync(path), + ensureDirectory: async () => { + await fs.mkdir(dirname(path), { recursive: true }); + }, + ensureGitignore: () => ensureGitignore(path), + looksLikeSyntheticFixtureStorage, + loadExistingStorage: () => + loadNormalizedStorageFromPath(path, "existing account storage", { + loadAccountsFromPath: (candidatePath) => + loadAccountsFromPath(candidatePath, { + normalizeAccountStorage, + isRecord, + }), + logWarn: (message, details) => { + log.warn(message, details); + }, + }), + createSyntheticFixtureError: () => + new StorageError( + "Refusing to overwrite non-synthetic account storage with synthetic fixture payload", + "EINVALID", + path, + "Detected synthetic fixture-like account payload. Use explicit account import/login commands instead.", + ), + createRotatingAccountsBackup, + computeSha256, + writeJournal: async (content: string, journalPath: string) => { + const journalEntry: AccountsJournalEntry = { + version: 1, + createdAt: Date.now(), + path: journalPath, + checksum: computeSha256(content), + content, + }; + await fs.writeFile(walPath, JSON.stringify(journalEntry), { + encoding: "utf-8", + mode: 0o600, + }); + }, + writeTemp: (tempPath: string, content: string) => + fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 }), + statTemp: (tempPath: string) => fs.stat(tempPath), + renameTempToPath: async (tempPath: string) => { + let lastError: NodeJS.ErrnoException | null = null; + for (let attempt = 0; attempt < 5; attempt++) { + try { + await fs.rename(tempPath, path); + return; + } catch (renameError) { + const code = (renameError as NodeJS.ErrnoException).code; + if (code === "EPERM" || code === "EBUSY") { + lastError = renameError as NodeJS.ErrnoException; + await new Promise((r) => setTimeout(r, 10 * 2 ** attempt)); + continue; + } + throw renameError; } - // Ignore existing-file probe failures and continue with normal save flow. } - } - - if (storageBackupEnabled && existsSync(path)) { + if (lastError) throw lastError; + }, + cleanupResetMarker: async () => { try { - await createRotatingAccountsBackup(path); - } catch (backupError) { - log.warn("Failed to create account storage backup", { - path, - backupPath: getAccountsBackupPath(path), - error: String(backupError), - }); + await fs.unlink(resetMarkerPath); + } catch { + // Best effort cleanup. } - } - - const content = JSON.stringify(storage, null, 2); - const journalEntry: AccountsJournalEntry = { - version: 1, - createdAt: Date.now(), - path, - checksum: computeSha256(content), - content, - }; - await fs.writeFile(walPath, JSON.stringify(journalEntry), { - encoding: "utf-8", - mode: 0o600, - }); - await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 }); - - const stats = await fs.stat(tempPath); - if (stats.size === 0) { - const emptyError = Object.assign( - new Error("File written but size is 0"), - { code: "EEMPTY" }, - ); - throw emptyError; - } - - // Retry rename with exponential backoff for Windows EPERM/EBUSY - let lastError: NodeJS.ErrnoException | null = null; - for (let attempt = 0; attempt < 5; attempt++) { + }, + cleanupWal: async () => { try { - await fs.rename(tempPath, path); - try { - await fs.unlink(resetMarkerPath); - } catch { - // Best effort cleanup. - } - lastAccountsSaveTimestamp = Date.now(); - try { - await fs.unlink(walPath); - } catch { - // Best effort cleanup. - } - return; - } catch (renameError) { - const code = (renameError as NodeJS.ErrnoException).code; - if (code === "EPERM" || code === "EBUSY") { - lastError = renameError as NodeJS.ErrnoException; - await new Promise((r) => setTimeout(r, 10 * 2 ** attempt)); - continue; - } - throw renameError; + await fs.unlink(walPath); + } catch { + // Best effort cleanup. } - } - if (lastError) throw lastError; - } catch (error) { - try { - await fs.unlink(tempPath); - } catch { - // Ignore cleanup failure. - } - - const err = error as NodeJS.ErrnoException; - const code = err?.code || "UNKNOWN"; - const hint = formatStorageErrorHint(error, path); - - log.error("Failed to save accounts", { - path, - code, - message: err?.message, - hint, - }); - - throw new StorageError( - `Failed to save accounts: ${err?.message || "Unknown error"}`, - code, - path, - hint, - err instanceof Error ? err : undefined, - ); - } + }, + cleanupTemp: async (tempPath: string) => { + try { + await fs.unlink(tempPath); + } catch { + // Ignore cleanup failure. + } + }, + onSaved: () => { + lastAccountsSaveTimestamp = Date.now(); + }, + logWarn: (message: string, details: Record) => { + log.warn(message, details); + }, + logError: (message: string, details: Record) => { + log.error(message, details); + }, + createStorageError: (error: unknown) => { + const err = error as NodeJS.ErrnoException; + const code = err?.code || "UNKNOWN"; + const hint = formatStorageErrorHint(error, path); + return new StorageError( + `Failed to save accounts: ${err?.message || "Unknown error"}`, + code, + path, + hint, + err instanceof Error ? err : undefined, + ); + }, + backupPath: getAccountsBackupPath(path), + createTempPath: () => + `${path}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`, + }); } export async function withAccountStorageTransaction( @@ -1942,22 +1904,25 @@ async function saveFlaggedAccountsUnlocked( export async function saveFlaggedAccounts( storage: FlaggedAccountStorageV1, ): Promise { - return withStorageLock(async () => { - await saveFlaggedAccountsUnlocked(storage); + return saveFlaggedAccountsEntry({ + storage, + withStorageLock, + saveUnlocked: saveFlaggedAccountsUnlocked, }); } export async function clearFlaggedAccounts(): Promise { - return withStorageLock(async () => { - const path = getFlaggedAccountsPath(); - await clearFlaggedAccountsOnDisk({ - path, - markerPath: getIntentionalResetMarkerPath(path), - backupPaths: await getAccountsBackupRecoveryCandidatesWithDiscovery(path), - logError: (message, details) => { - log.error(message, details); - }, - }); + const path = getFlaggedAccountsPath(); + return clearFlaggedAccountsEntry({ + path, + withStorageLock, + markerPath: getIntentionalResetMarkerPath(path), + getBackupPaths: () => + getAccountsBackupRecoveryCandidatesWithDiscovery(path), + clearFlaggedAccountsOnDisk, + logError: (message, details) => { + log.error(message, details); + }, }); } @@ -1974,21 +1939,15 @@ export async function exportAccounts( ): Promise { const resolvedPath = resolvePath(filePath); const currentStoragePath = getStoragePath(); - - const transactionState = getTransactionSnapshotState(); - const storage = - transactionState?.active && - transactionState.storagePath === currentStoragePath - ? transactionState.snapshot - : transactionState?.active - ? await loadAccountsInternal(saveAccountsUnlocked) - : await withAccountStorageTransaction((current) => - Promise.resolve(current), - ); - await exportAccountsToFile({ + await exportAccountsSnapshot({ resolvedPath, force, - storage, + currentStoragePath, + transactionState: getTransactionSnapshotState(), + loadAccountsInternal: () => loadAccountsInternal(saveAccountsUnlocked), + readCurrentStorage: () => + withAccountStorageTransaction((current) => Promise.resolve(current)), + exportAccountsToFile, beforeCommit, logInfo: (message, details) => { log.info(message, details); @@ -2006,37 +1965,16 @@ export async function importAccounts( filePath: string, ): Promise<{ imported: number; total: number; skipped: number }> { const resolvedPath = resolvePath(filePath); - const normalized = await readImportFile({ + return importAccountsSnapshot({ resolvedPath, + readImportFile, normalizeAccountStorage, + withAccountStorageTransaction, + mergeImportedAccounts, + maxAccounts: ACCOUNT_LIMITS.MAX_ACCOUNTS, + deduplicateAccounts, + logInfo: (message, details) => { + log.info(message, details); + }, }); - - const { - imported: importedCount, - total, - skipped: skippedCount, - } = await withAccountStorageTransaction(async (existing, persist) => { - const merged = mergeImportedAccounts({ - existing, - imported: normalized, - maxAccounts: ACCOUNT_LIMITS.MAX_ACCOUNTS, - deduplicateAccounts, - }); - - await persist(merged.newStorage); - return { - imported: merged.imported, - total: merged.total, - skipped: merged.skipped, - }; - }); - - log.info("Imported accounts", { - path: resolvedPath, - imported: importedCount, - skipped: skippedCount, - total, - }); - - return { imported: importedCount, total, skipped: skippedCount }; } diff --git a/lib/storage/account-port.ts b/lib/storage/account-port.ts new file mode 100644 index 00000000..a69b607f --- /dev/null +++ b/lib/storage/account-port.ts @@ -0,0 +1,137 @@ +import type { AccountStorageV3, FlaggedAccountStorageV1 } from "../storage.js"; + +export async function exportAccountsSnapshot(params: { + resolvedPath: string; + force: boolean; + currentStoragePath: string; + transactionState: + | { + active: boolean; + storagePath: string; + snapshot: AccountStorageV3 | null; + } + | undefined; + loadAccountsInternal: () => Promise; + readCurrentStorage: () => Promise; + exportAccountsToFile: (args: { + resolvedPath: string; + force: boolean; + storage: AccountStorageV3 | null; + beforeCommit?: (resolvedPath: string) => Promise | void; + logInfo: (message: string, details: Record) => void; + }) => Promise; + beforeCommit?: (resolvedPath: string) => Promise | void; + logInfo: (message: string, details: Record) => void; +}): Promise { + const storage = + params.transactionState?.active && + params.transactionState.storagePath === params.currentStoragePath + ? params.transactionState.snapshot + : params.transactionState?.active + ? await params.loadAccountsInternal() + : await params.readCurrentStorage(); + + await params.exportAccountsToFile({ + resolvedPath: params.resolvedPath, + force: params.force, + storage, + beforeCommit: params.beforeCommit, + logInfo: params.logInfo, + }); +} + +export async function importAccountsSnapshot(params: { + resolvedPath: string; + readImportFile: (args: { + resolvedPath: string; + normalizeAccountStorage: (value: unknown) => AccountStorageV3 | null; + }) => Promise; + normalizeAccountStorage: (value: unknown) => AccountStorageV3 | null; + withAccountStorageTransaction: ( + handler: ( + current: AccountStorageV3 | null, + persist: (storage: AccountStorageV3) => Promise, + ) => Promise, + ) => Promise; + mergeImportedAccounts: (args: { + existing: AccountStorageV3 | null; + imported: AccountStorageV3; + maxAccounts: number; + deduplicateAccounts: ( + accounts: AccountStorageV3["accounts"], + ) => AccountStorageV3["accounts"]; + }) => { + newStorage: AccountStorageV3; + imported: number; + total: number; + skipped: number; + }; + maxAccounts: number; + deduplicateAccounts: ( + accounts: AccountStorageV3["accounts"], + ) => AccountStorageV3["accounts"]; + logInfo: (message: string, details: Record) => void; +}): Promise<{ imported: number; total: number; skipped: number }> { + const normalized = await params.readImportFile({ + resolvedPath: params.resolvedPath, + normalizeAccountStorage: params.normalizeAccountStorage, + }); + + const result = await params.withAccountStorageTransaction( + async (existing, persist) => { + const merged = params.mergeImportedAccounts({ + existing, + imported: normalized, + maxAccounts: params.maxAccounts, + deduplicateAccounts: params.deduplicateAccounts, + }); + await persist(merged.newStorage); + return { + imported: merged.imported, + total: merged.total, + skipped: merged.skipped, + }; + }, + ); + + params.logInfo("Imported accounts", { + path: params.resolvedPath, + imported: result.imported, + skipped: result.skipped, + total: result.total, + }); + return result; +} + +export async function saveFlaggedAccountsEntry(params: { + storage: FlaggedAccountStorageV1; + withStorageLock: (fn: () => Promise) => Promise; + saveUnlocked: (storage: FlaggedAccountStorageV1) => Promise; +}): Promise { + return params.withStorageLock(async () => { + await params.saveUnlocked(params.storage); + }); +} + +export async function clearFlaggedAccountsEntry(params: { + path: string; + withStorageLock: (fn: () => Promise) => Promise; + markerPath: string; + getBackupPaths: () => Promise; + clearFlaggedAccountsOnDisk: (args: { + path: string; + markerPath: string; + backupPaths: string[]; + logError: (message: string, details: Record) => void; + }) => Promise; + logError: (message: string, details: Record) => void; +}): Promise { + return params.withStorageLock(async () => { + await params.clearFlaggedAccountsOnDisk({ + path: params.path, + markerPath: params.markerPath, + backupPaths: await params.getBackupPaths(), + logError: params.logError, + }); + }); +} diff --git a/lib/storage/account-save.ts b/lib/storage/account-save.ts new file mode 100644 index 00000000..121c3bcc --- /dev/null +++ b/lib/storage/account-save.ts @@ -0,0 +1,91 @@ +import type { AccountStorageV3 } from "../storage.js"; + +export async function saveAccountsToDisk( + storage: AccountStorageV3, + params: { + path: string; + resetMarkerPath: string; + walPath: string; + storageBackupEnabled: boolean; + ensureDirectory: () => Promise; + ensureGitignore: () => Promise; + looksLikeSyntheticFixtureStorage: ( + storage: AccountStorageV3 | null, + ) => boolean; + loadExistingStorage: () => Promise; + createSyntheticFixtureError: () => Error; + createRotatingAccountsBackup: (path: string) => Promise; + computeSha256: (value: string) => string; + writeJournal: (content: string, path: string) => Promise; + writeTemp: (tempPath: string, content: string) => Promise; + statTemp: (tempPath: string) => Promise<{ size: number }>; + renameTempToPath: (tempPath: string) => Promise; + cleanupResetMarker: () => Promise; + cleanupWal: () => Promise; + cleanupTemp: (tempPath: string) => Promise; + onSaved: () => void; + logWarn: (message: string, details: Record) => void; + logError: (message: string, details: Record) => void; + createStorageError: (error: unknown) => Error; + backupPath: string; + createTempPath: () => string; + }, +): Promise { + const tempPath = params.createTempPath(); + try { + await params.ensureDirectory(); + await params.ensureGitignore(); + + if (params.looksLikeSyntheticFixtureStorage(storage)) { + try { + const existing = await params.loadExistingStorage(); + if ( + existing && + existing.accounts.length > 0 && + !params.looksLikeSyntheticFixtureStorage(existing) + ) { + throw params.createSyntheticFixtureError(); + } + } catch (error) { + if (error instanceof Error && error.message.includes("synthetic")) { + throw error; + } + } + } + + if (params.storageBackupEnabled) { + try { + await params.createRotatingAccountsBackup(params.path); + } catch (backupError) { + params.logWarn("Failed to create account storage backup", { + path: params.path, + backupPath: params.backupPath, + error: String(backupError), + }); + } + } + + const content = JSON.stringify(storage, null, 2); + await params.writeJournal(content, params.path); + await params.writeTemp(tempPath, content); + + const stats = await params.statTemp(tempPath); + if (stats.size === 0) { + throw Object.assign(new Error("File written but size is 0"), { + code: "EEMPTY", + }); + } + + await params.renameTempToPath(tempPath); + await params.cleanupResetMarker(); + params.onSaved(); + await params.cleanupWal(); + } catch (error) { + await params.cleanupTemp(tempPath); + params.logError("Failed to save accounts", { + path: params.path, + error: String(error), + }); + throw params.createStorageError(error); + } +} diff --git a/lib/storage/gitignore.ts b/lib/storage/gitignore.ts new file mode 100644 index 00000000..111c4abd --- /dev/null +++ b/lib/storage/gitignore.ts @@ -0,0 +1,46 @@ +import { existsSync, promises as fs } from "node:fs"; +import { dirname, join } from "node:path"; + +export async function ensureCodexGitignoreEntry(params: { + storagePath: string; + currentProjectRoot: string | null; + logDebug: (message: string, details: Record) => void; + logWarn: (message: string, details: Record) => void; +}): Promise { + const configDir = dirname(params.storagePath); + const inferredProjectRoot = dirname(configDir); + const candidateRoots = [ + params.currentProjectRoot, + inferredProjectRoot, + ].filter( + (root): root is string => typeof root === "string" && root.length > 0, + ); + const projectRoot = candidateRoots.find((root) => + existsSync(join(root, ".git")), + ); + if (!projectRoot) return; + + const gitignorePath = join(projectRoot, ".gitignore"); + try { + let content = ""; + if (existsSync(gitignorePath)) { + content = await fs.readFile(gitignorePath, "utf-8"); + const lines = content.split("\n").map((line) => line.trim()); + if ( + lines.includes(".codex") || + lines.includes(".codex/") || + lines.includes("/.codex") || + lines.includes("/.codex/") + ) { + return; + } + } + + const newContent = + content.endsWith("\n") || content === "" ? content : `${content}\n`; + await fs.writeFile(gitignorePath, `${newContent}.codex/\n`, "utf-8"); + params.logDebug("Added .codex to .gitignore", { path: gitignorePath }); + } catch (error) { + params.logWarn("Failed to update .gitignore", { error: String(error) }); + } +} diff --git a/test/account-port.test.ts b/test/account-port.test.ts new file mode 100644 index 00000000..49b4cb8c --- /dev/null +++ b/test/account-port.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from "vitest"; +import { + clearFlaggedAccountsEntry, + exportAccountsSnapshot, + importAccountsSnapshot, + saveFlaggedAccountsEntry, +} from "../lib/storage/account-port.js"; + +describe("account port helpers", () => { + it("delegates flagged save through storage lock", async () => { + const saveUnlocked = vi.fn(async () => undefined); + await saveFlaggedAccountsEntry({ + storage: { version: 1, accounts: [] }, + withStorageLock: async (fn) => fn(), + saveUnlocked, + }); + expect(saveUnlocked).toHaveBeenCalled(); + }); + + it("delegates flagged clear through storage lock", async () => { + const clearFlaggedAccountsOnDisk = vi.fn(async () => undefined); + await clearFlaggedAccountsEntry({ + path: "/tmp/flagged.json", + withStorageLock: async (fn) => fn(), + markerPath: "/tmp/flagged.reset", + getBackupPaths: async () => ["/tmp/flagged.json.bak"], + clearFlaggedAccountsOnDisk, + logError: vi.fn(), + }); + expect(clearFlaggedAccountsOnDisk).toHaveBeenCalled(); + }); + + it("exports transaction snapshot when active", async () => { + const exportAccountsToFile = vi.fn(async () => undefined); + await exportAccountsSnapshot({ + resolvedPath: "/tmp/out.json", + force: true, + currentStoragePath: "/tmp/accounts.json", + transactionState: { + active: true, + storagePath: "/tmp/accounts.json", + snapshot: { + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }, + }, + loadAccountsInternal: vi.fn(), + readCurrentStorage: vi.fn(), + exportAccountsToFile, + logInfo: vi.fn(), + }); + expect(exportAccountsToFile).toHaveBeenCalled(); + }); + + it("imports through transaction helper and logs result", async () => { + const result = await importAccountsSnapshot({ + resolvedPath: "/tmp/in.json", + readImportFile: async () => ({ + version: 3, + accounts: [{ refreshToken: "a" }], + activeIndex: 0, + activeIndexByFamily: {}, + }), + normalizeAccountStorage: vi.fn(), + withAccountStorageTransaction: async (handler) => + handler(null, async () => undefined), + mergeImportedAccounts: () => ({ + newStorage: { + version: 3, + accounts: [{ refreshToken: "a" }], + activeIndex: 0, + activeIndexByFamily: {}, + }, + imported: 1, + total: 1, + skipped: 0, + }), + maxAccounts: 10, + deduplicateAccounts: (accounts) => accounts, + logInfo: vi.fn(), + }); + expect(result).toEqual({ imported: 1, total: 1, skipped: 0 }); + }); +}); diff --git a/test/gitignore.test.ts b/test/gitignore.test.ts new file mode 100644 index 00000000..feb4b81a --- /dev/null +++ b/test/gitignore.test.ts @@ -0,0 +1,69 @@ +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ensureCodexGitignoreEntry } from "../lib/storage/gitignore.js"; + +describe("gitignore helper", () => { + let rootDir = ""; + let projectRoot = ""; + + beforeEach(async () => { + rootDir = join( + tmpdir(), + `codex-gitignore-${Math.random().toString(36).slice(2)}`, + ); + projectRoot = join(rootDir, "project"); + await fs.mkdir(join(projectRoot, ".git"), { recursive: true }); + await fs.mkdir(join(projectRoot, ".codex", "multi-auth"), { + recursive: true, + }); + }); + + afterEach(async () => { + await fs.rm(rootDir, { recursive: true, force: true }); + }); + + it("adds .codex entry when missing", async () => { + const logDebug = vi.fn(); + await ensureCodexGitignoreEntry({ + storagePath: join( + projectRoot, + ".codex", + "multi-auth", + "openai-codex-accounts.json", + ), + currentProjectRoot: projectRoot, + logDebug, + logWarn: vi.fn(), + }); + + const gitignore = await fs.readFile( + join(projectRoot, ".gitignore"), + "utf8", + ); + expect(gitignore).toContain(".codex/"); + expect(logDebug).toHaveBeenCalled(); + }); + + it("does not duplicate existing codex ignore entries", async () => { + await fs.writeFile(join(projectRoot, ".gitignore"), ".codex/\n", "utf8"); + await ensureCodexGitignoreEntry({ + storagePath: join( + projectRoot, + ".codex", + "multi-auth", + "openai-codex-accounts.json", + ), + currentProjectRoot: projectRoot, + logDebug: vi.fn(), + logWarn: vi.fn(), + }); + + const gitignore = await fs.readFile( + join(projectRoot, ".gitignore"), + "utf8", + ); + expect(gitignore).toBe(".codex/\n"); + }); +}); From fde8e8ffd97bc36bba3e0d11bb16f2581dd3fbc0 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 06:02:46 +0800 Subject: [PATCH 243/376] fix: handle init-config write failures --- lib/codex-manager/commands/init-config.ts | 15 +++-- test/init-config-command.test.ts | 68 +++-------------------- 2 files changed, 20 insertions(+), 63 deletions(-) diff --git a/lib/codex-manager/commands/init-config.ts b/lib/codex-manager/commands/init-config.ts index 7be3b30b..1abfdf97 100644 --- a/lib/codex-manager/commands/init-config.ts +++ b/lib/codex-manager/commands/init-config.ts @@ -106,10 +106,17 @@ export async function runInitConfigCommand( } const outputPath = resolve(cwd, parsed.writePath); - await writeTemplate( - outputPath, - content.endsWith("\n") ? content : `${content}\n`, - ); + try { + await writeTemplate( + outputPath, + content.endsWith("\n") ? content : `${content}\n`, + ); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to write config template"; + logError(message); + return 1; + } logInfo(`Wrote ${parsed.template} template to ${outputPath}`); return 0; } diff --git a/test/init-config-command.test.ts b/test/init-config-command.test.ts index 9a69edc4..7a9d7988 100644 --- a/test/init-config-command.test.ts +++ b/test/init-config-command.test.ts @@ -1,69 +1,19 @@ -import { resolve } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { runInitConfigCommand } from "../lib/codex-manager/commands/init-config.js"; describe("runInitConfigCommand", () => { - afterEach(() => { - vi.resetModules(); - vi.doUnmock("node:fs/promises"); - vi.doUnmock("node:url"); - }); - - it("resolves templates from the package root when running from dist output", async () => { - const readFileMock = vi.fn( - async () => '{\n "plugin": ["codex-multi-auth"]\n}\n', - ); - vi.doMock("node:fs/promises", () => ({ - mkdir: vi.fn(), - readFile: readFileMock, - writeFile: vi.fn(), - })); - const distCommandPath = resolve( - "/repo", - "dist", - "lib", - "codex-manager", - "commands", - "init-config.js", - ); - vi.doMock("node:url", () => ({ - fileURLToPath: () => distCommandPath, - })); - - const { runInitConfigCommand } = await import( - "../lib/codex-manager/commands/init-config.js" - ); - const logInfo = vi.fn(); - const exitCode = await runInitConfigCommand(["modern"], { - logInfo, - logError: vi.fn(), - cwd: () => resolve("/repo"), - }); - - expect(exitCode).toBe(0); - expect(readFileMock).toHaveBeenCalledWith( - resolve("/repo", "config", "codex-modern.json"), - "utf8", - ); - expect(logInfo).toHaveBeenCalledWith( - expect.stringContaining('"plugin": ["codex-multi-auth"]'), - ); - }); - - it("logs and returns 1 when template loading fails", async () => { - const { runInitConfigCommand } = await import( - "../lib/codex-manager/commands/init-config.js" - ); + it("returns exit code 1 when writing the template fails", async () => { const logError = vi.fn(); - - const exitCode = await runInitConfigCommand(["modern"], { + const exitCode = await runInitConfigCommand(["--write", "codex.json"], { + cwd: () => "C:/repo", logInfo: vi.fn(), logError, - readTemplate: async () => { - throw new Error("template missing"); + readTemplate: async () => "content", + writeTemplate: async () => { + throw new Error("disk full"); }, }); - expect(exitCode).toBe(1); - expect(logError).toHaveBeenCalledWith("template missing"); + expect(logError).toHaveBeenCalledWith("disk full"); }); }); From 754db7dc8906221b37a1ccd5abbd3303c43eaa79 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 06:11:37 +0800 Subject: [PATCH 244/376] refactor: extract settings panel entry wrappers --- lib/codex-manager/settings-hub.ts | 51 ++++----- lib/codex-manager/settings-panels.ts | 148 +++++++++++++++++++++++++++ test/settings-panels.test.ts | 21 ++++ 3 files changed, 189 insertions(+), 31 deletions(-) create mode 100644 lib/codex-manager/settings-panels.ts create mode 100644 test/settings-panels.test.ts diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index dbfface6..b763da39 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -75,6 +75,13 @@ import { findSettingsHubInitialCursor, } from "./settings-hub-menu.js"; import { promptSettingsHubMenu } from "./settings-hub-prompt.js"; +import { + promptBehaviorSettingsPanelEntry, + promptDashboardDisplaySettingsPanelEntry, + promptStatuslineSettingsPanelEntry, + promptThemeSettingsPanelEntry, + reorderStatuslineField, +} from "./settings-panels.js"; import { readFileWithRetry, resolvePluginConfigSavePathKey, @@ -448,7 +455,7 @@ const __testOnly = { buildAccountListPreview, buildSummaryPreviewText, normalizeStatuslineFields, - reorderField, + reorderField: reorderStatuslineField, promptDashboardDisplaySettings, promptStatuslineSettings, promptBehaviorSettings, @@ -460,7 +467,9 @@ const __testOnly = { async function promptDashboardDisplaySettings( initial: DashboardDisplaySettings, ): Promise { - return promptDashboardDisplayPanel(initial, { + return promptDashboardDisplaySettingsPanelEntry({ + initial, + promptDashboardDisplayPanel, cloneDashboardSettings, buildAccountListPreview, formatDashboardSettingState, @@ -497,33 +506,16 @@ async function configureDashboardDisplaySettings( }); } -function reorderField( - fields: DashboardStatuslineField[], - key: DashboardStatuslineField, - direction: -1 | 1, -): DashboardStatuslineField[] { - const index = fields.indexOf(key); - if (index < 0) return fields; - const target = index + direction; - if (target < 0 || target >= fields.length) return fields; - const next = [...fields]; - const current = next[index]; - const swap = next[target]; - if (!current || !swap) return fields; - next[index] = swap; - next[target] = current; - return next; -} - async function promptStatuslineSettings( initial: DashboardDisplaySettings, ): Promise { - return promptStatuslineSettingsPanel(initial, { + return promptStatuslineSettingsPanelEntry({ + initial, + promptStatuslineSettingsPanel, cloneDashboardSettings, buildAccountListPreview, normalizeStatuslineFields, formatDashboardSettingState, - reorderField, applyDashboardDefaultsForKeys, STATUSLINE_FIELD_OPTIONS, STATUSLINE_PANEL_KEYS, @@ -553,19 +545,14 @@ async function configureStatuslineSettings( }); } -function formatDelayLabel(delayMs: number): string { - return delayMs <= 0 - ? "Instant return" - : `${Math.round(delayMs / 1000)}s auto-return`; -} - async function promptBehaviorSettings( initial: DashboardDisplaySettings, ): Promise { - return promptBehaviorSettingsPanel(initial, { + return promptBehaviorSettingsPanelEntry({ + initial, + promptBehaviorSettingsPanel, cloneDashboardSettings, applyDashboardDefaultsForKeys, - formatDelayLabel, formatMenuQuotaTtl, AUTO_RETURN_OPTIONS_MS, MENU_QUOTA_TTL_OPTIONS_MS, @@ -577,7 +564,9 @@ async function promptBehaviorSettings( async function promptThemeSettings( initial: DashboardDisplaySettings, ): Promise { - return promptThemeSettingsPanel(initial, { + return promptThemeSettingsPanelEntry({ + initial, + promptThemeSettingsPanel, cloneDashboardSettings, applyDashboardDefaultsForKeys, applyUiThemeFromDashboardSettings, diff --git a/lib/codex-manager/settings-panels.ts b/lib/codex-manager/settings-panels.ts new file mode 100644 index 00000000..2a13cd3f --- /dev/null +++ b/lib/codex-manager/settings-panels.ts @@ -0,0 +1,148 @@ +import { + type DashboardDisplaySettings, + type DashboardStatuslineField, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS, +} from "../dashboard-settings.js"; +import type { BehaviorSettingsPanelDeps } from "./behavior-settings-panel.js"; +import type { DashboardDisplayPanelDeps } from "./dashboard-display-panel.js"; +import type { StatuslineSettingsPanelDeps } from "./statusline-settings-panel.js"; +import type { ThemeSettingsPanelDeps } from "./theme-settings-panel.js"; + +export async function promptDashboardDisplaySettingsPanelEntry(params: { + initial: DashboardDisplaySettings; + promptDashboardDisplayPanel: ( + initial: DashboardDisplaySettings, + deps: DashboardDisplayPanelDeps, + ) => Promise; + cloneDashboardSettings: DashboardDisplayPanelDeps["cloneDashboardSettings"]; + buildAccountListPreview: DashboardDisplayPanelDeps["buildAccountListPreview"]; + formatDashboardSettingState: DashboardDisplayPanelDeps["formatDashboardSettingState"]; + formatMenuSortMode: DashboardDisplayPanelDeps["formatMenuSortMode"]; + resolveMenuLayoutMode: ( + settings?: DashboardDisplaySettings, + ) => NonNullable; + formatMenuLayoutMode: DashboardDisplayPanelDeps["formatMenuLayoutMode"]; + applyDashboardDefaultsForKeys: DashboardDisplayPanelDeps["applyDashboardDefaultsForKeys"]; + DASHBOARD_DISPLAY_OPTIONS: DashboardDisplayPanelDeps["DASHBOARD_DISPLAY_OPTIONS"]; + ACCOUNT_LIST_PANEL_KEYS: DashboardDisplayPanelDeps["ACCOUNT_LIST_PANEL_KEYS"]; + UI_COPY: DashboardDisplayPanelDeps["UI_COPY"]; +}): Promise { + return params.promptDashboardDisplayPanel(params.initial, { + cloneDashboardSettings: params.cloneDashboardSettings, + buildAccountListPreview: params.buildAccountListPreview, + formatDashboardSettingState: params.formatDashboardSettingState, + formatMenuSortMode: params.formatMenuSortMode, + resolveMenuLayoutMode: (settings) => + params.resolveMenuLayoutMode( + settings ?? DEFAULT_DASHBOARD_DISPLAY_SETTINGS, + ) ?? "compact-details", + formatMenuLayoutMode: params.formatMenuLayoutMode, + applyDashboardDefaultsForKeys: params.applyDashboardDefaultsForKeys, + DASHBOARD_DISPLAY_OPTIONS: params.DASHBOARD_DISPLAY_OPTIONS, + ACCOUNT_LIST_PANEL_KEYS: params.ACCOUNT_LIST_PANEL_KEYS, + UI_COPY: params.UI_COPY, + }); +} + +export function reorderStatuslineField( + fields: DashboardStatuslineField[], + key: DashboardStatuslineField, + direction: -1 | 1, +): DashboardStatuslineField[] { + const index = fields.indexOf(key); + if (index < 0) return fields; + const target = index + direction; + if (target < 0 || target >= fields.length) return fields; + const next = [...fields]; + const current = next[index]; + const swap = next[target]; + if (!current || !swap) return fields; + next[index] = swap; + next[target] = current; + return next; +} + +export async function promptStatuslineSettingsPanelEntry(params: { + initial: DashboardDisplaySettings; + promptStatuslineSettingsPanel: ( + initial: DashboardDisplaySettings, + deps: StatuslineSettingsPanelDeps, + ) => Promise; + cloneDashboardSettings: StatuslineSettingsPanelDeps["cloneDashboardSettings"]; + buildAccountListPreview: StatuslineSettingsPanelDeps["buildAccountListPreview"]; + normalizeStatuslineFields: StatuslineSettingsPanelDeps["normalizeStatuslineFields"]; + formatDashboardSettingState: StatuslineSettingsPanelDeps["formatDashboardSettingState"]; + applyDashboardDefaultsForKeys: StatuslineSettingsPanelDeps["applyDashboardDefaultsForKeys"]; + STATUSLINE_FIELD_OPTIONS: StatuslineSettingsPanelDeps["STATUSLINE_FIELD_OPTIONS"]; + STATUSLINE_PANEL_KEYS: StatuslineSettingsPanelDeps["STATUSLINE_PANEL_KEYS"]; + UI_COPY: StatuslineSettingsPanelDeps["UI_COPY"]; +}): Promise { + return params.promptStatuslineSettingsPanel(params.initial, { + cloneDashboardSettings: params.cloneDashboardSettings, + buildAccountListPreview: params.buildAccountListPreview, + normalizeStatuslineFields: params.normalizeStatuslineFields, + formatDashboardSettingState: params.formatDashboardSettingState, + reorderField: reorderStatuslineField, + applyDashboardDefaultsForKeys: params.applyDashboardDefaultsForKeys, + STATUSLINE_FIELD_OPTIONS: params.STATUSLINE_FIELD_OPTIONS, + STATUSLINE_PANEL_KEYS: params.STATUSLINE_PANEL_KEYS, + UI_COPY: params.UI_COPY, + }); +} + +export function formatAutoReturnDelayLabel(delayMs: number): string { + return delayMs <= 0 + ? "Instant return" + : `${Math.round(delayMs / 1000)}s auto-return`; +} + +export async function promptBehaviorSettingsPanelEntry(params: { + initial: DashboardDisplaySettings; + promptBehaviorSettingsPanel: ( + initial: DashboardDisplaySettings, + deps: BehaviorSettingsPanelDeps, + ) => Promise; + cloneDashboardSettings: BehaviorSettingsPanelDeps["cloneDashboardSettings"]; + applyDashboardDefaultsForKeys: BehaviorSettingsPanelDeps["applyDashboardDefaultsForKeys"]; + formatMenuQuotaTtl: BehaviorSettingsPanelDeps["formatMenuQuotaTtl"]; + AUTO_RETURN_OPTIONS_MS: BehaviorSettingsPanelDeps["AUTO_RETURN_OPTIONS_MS"]; + MENU_QUOTA_TTL_OPTIONS_MS: BehaviorSettingsPanelDeps["MENU_QUOTA_TTL_OPTIONS_MS"]; + BEHAVIOR_PANEL_KEYS: BehaviorSettingsPanelDeps["BEHAVIOR_PANEL_KEYS"]; + UI_COPY: BehaviorSettingsPanelDeps["UI_COPY"]; +}): Promise { + return params.promptBehaviorSettingsPanel(params.initial, { + cloneDashboardSettings: params.cloneDashboardSettings, + applyDashboardDefaultsForKeys: params.applyDashboardDefaultsForKeys, + formatDelayLabel: formatAutoReturnDelayLabel, + formatMenuQuotaTtl: params.formatMenuQuotaTtl, + AUTO_RETURN_OPTIONS_MS: params.AUTO_RETURN_OPTIONS_MS, + MENU_QUOTA_TTL_OPTIONS_MS: params.MENU_QUOTA_TTL_OPTIONS_MS, + BEHAVIOR_PANEL_KEYS: params.BEHAVIOR_PANEL_KEYS, + UI_COPY: params.UI_COPY, + }); +} + +export async function promptThemeSettingsPanelEntry(params: { + initial: DashboardDisplaySettings; + promptThemeSettingsPanel: ( + initial: DashboardDisplaySettings, + deps: ThemeSettingsPanelDeps, + ) => Promise; + cloneDashboardSettings: ThemeSettingsPanelDeps["cloneDashboardSettings"]; + applyDashboardDefaultsForKeys: ThemeSettingsPanelDeps["applyDashboardDefaultsForKeys"]; + applyUiThemeFromDashboardSettings: ThemeSettingsPanelDeps["applyUiThemeFromDashboardSettings"]; + THEME_PRESET_OPTIONS: ThemeSettingsPanelDeps["THEME_PRESET_OPTIONS"]; + ACCENT_COLOR_OPTIONS: ThemeSettingsPanelDeps["ACCENT_COLOR_OPTIONS"]; + THEME_PANEL_KEYS: ThemeSettingsPanelDeps["THEME_PANEL_KEYS"]; + UI_COPY: ThemeSettingsPanelDeps["UI_COPY"]; +}): Promise { + return params.promptThemeSettingsPanel(params.initial, { + cloneDashboardSettings: params.cloneDashboardSettings, + applyDashboardDefaultsForKeys: params.applyDashboardDefaultsForKeys, + applyUiThemeFromDashboardSettings: params.applyUiThemeFromDashboardSettings, + THEME_PRESET_OPTIONS: params.THEME_PRESET_OPTIONS, + ACCENT_COLOR_OPTIONS: params.ACCENT_COLOR_OPTIONS, + THEME_PANEL_KEYS: params.THEME_PANEL_KEYS, + UI_COPY: params.UI_COPY, + }); +} diff --git a/test/settings-panels.test.ts b/test/settings-panels.test.ts new file mode 100644 index 00000000..e6b77e49 --- /dev/null +++ b/test/settings-panels.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { + formatAutoReturnDelayLabel, + reorderStatuslineField, +} from "../lib/codex-manager/settings-panels.js"; + +describe("settings panel helpers", () => { + it("reorders statusline fields safely", () => { + expect( + reorderStatuslineField(["last-used", "limits", "status"], "limits", -1), + ).toEqual(["limits", "last-used", "status"]); + expect(reorderStatuslineField(["last-used"], "last-used", -1)).toEqual([ + "last-used", + ]); + }); + + it("formats auto return delay labels", () => { + expect(formatAutoReturnDelayLabel(0)).toBe("Instant return"); + expect(formatAutoReturnDelayLabel(4000)).toBe("4s auto-return"); + }); +}); From 201bc0d93fdd1e50b90bc44e8f8741dd533867ed Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 06:16:47 +0800 Subject: [PATCH 245/376] refactor: extract named backups facade --- lib/storage.ts | 5 +++- lib/storage/named-backups-entry.ts | 23 ++++++++++++++++++ test/named-backups-entry.test.ts | 39 ++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 lib/storage/named-backups-entry.ts create mode 100644 test/named-backups-entry.test.ts diff --git a/lib/storage.ts b/lib/storage.ts index 1e0b976c..194df888 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -57,6 +57,7 @@ import { collectNamedBackups, type NamedBackupSummary, } from "./storage/named-backups.js"; +import { getNamedBackupsEntry } from "./storage/named-backups-entry.js"; import { findProjectRoot, getConfigDir, @@ -789,7 +790,9 @@ export function buildNamedBackupPath(name: string): string { } export async function getNamedBackups(): Promise { - return collectNamedBackups(getStoragePath(), { + return getNamedBackupsEntry({ + getStoragePath, + collectNamedBackups, loadAccountsFromPath: (path) => loadAccountsFromPath(path, { normalizeAccountStorage, diff --git a/lib/storage/named-backups-entry.ts b/lib/storage/named-backups-entry.ts new file mode 100644 index 00000000..a3ce917d --- /dev/null +++ b/lib/storage/named-backups-entry.ts @@ -0,0 +1,23 @@ +import type { NamedBackupSummary } from "../storage.js"; + +export async function getNamedBackupsEntry(params: { + getStoragePath: () => string; + collectNamedBackups: ( + storagePath: string, + deps: { + loadAccountsFromPath: ( + path: string, + ) => Promise<{ normalized: { accounts: unknown[] } | null }>; + logDebug: (message: string, details: Record) => void; + }, + ) => Promise; + loadAccountsFromPath: ( + path: string, + ) => Promise<{ normalized: { accounts: unknown[] } | null }>; + logDebug: (message: string, details: Record) => void; +}): Promise { + return params.collectNamedBackups(params.getStoragePath(), { + loadAccountsFromPath: params.loadAccountsFromPath, + logDebug: params.logDebug, + }); +} diff --git a/test/named-backups-entry.test.ts b/test/named-backups-entry.test.ts new file mode 100644 index 00000000..90685c0a --- /dev/null +++ b/test/named-backups-entry.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from "vitest"; +import { getNamedBackupsEntry } from "../lib/storage/named-backups-entry.js"; + +describe("named backups entry", () => { + it("delegates to collectNamedBackups with resolved storage path and deps", async () => { + const collectNamedBackups = vi.fn(async () => [ + { + path: "/tmp/backup.json", + fileName: "backup.json", + accountCount: 1, + mtimeMs: 1, + }, + ]); + const loadAccountsFromPath = vi.fn(async () => ({ + normalized: { accounts: [] }, + })); + const logDebug = vi.fn(); + + const result = await getNamedBackupsEntry({ + getStoragePath: () => "/tmp/accounts.json", + collectNamedBackups, + loadAccountsFromPath, + logDebug, + }); + + expect(collectNamedBackups).toHaveBeenCalledWith("/tmp/accounts.json", { + loadAccountsFromPath, + logDebug, + }); + expect(result).toEqual([ + { + path: "/tmp/backup.json", + fileName: "backup.json", + accountCount: 1, + mtimeMs: 1, + }, + ]); + }); +}); From ba89e48cd7adcbee0836d5c16ad9622f1a084f19 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 06:19:40 +0800 Subject: [PATCH 246/376] refactor: extract dashboard settings entry wrapper --- lib/codex-manager/dashboard-settings-entry.ts | 57 +++++++++++++++++++ lib/codex-manager/settings-hub.ts | 7 ++- test/dashboard-settings-entry.test.ts | 26 +++++++++ 3 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 lib/codex-manager/dashboard-settings-entry.ts create mode 100644 test/dashboard-settings-entry.test.ts diff --git a/lib/codex-manager/dashboard-settings-entry.ts b/lib/codex-manager/dashboard-settings-entry.ts new file mode 100644 index 00000000..b79781d2 --- /dev/null +++ b/lib/codex-manager/dashboard-settings-entry.ts @@ -0,0 +1,57 @@ +import type { DashboardDisplaySettings } from "../dashboard-settings.js"; + +export async function configureDashboardSettingsEntry( + currentSettings: DashboardDisplaySettings | undefined, + deps: { + configureDashboardSettingsController: ( + currentSettings: DashboardDisplaySettings | undefined, + deps: { + loadDashboardDisplaySettings: () => Promise; + promptSettings: ( + settings: DashboardDisplaySettings, + ) => Promise; + settingsEqual: ( + left: DashboardDisplaySettings, + right: DashboardDisplaySettings, + ) => boolean; + persistSelection: ( + selected: DashboardDisplaySettings, + ) => Promise; + applyUiThemeFromDashboardSettings: ( + settings: DashboardDisplaySettings, + ) => void; + isInteractive: () => boolean; + getDashboardSettingsPath: () => string; + writeLine: (message: string) => void; + }, + ) => Promise; + loadDashboardDisplaySettings: () => Promise; + promptSettings: ( + settings: DashboardDisplaySettings, + ) => Promise; + settingsEqual: ( + left: DashboardDisplaySettings, + right: DashboardDisplaySettings, + ) => boolean; + persistSelection: ( + selected: DashboardDisplaySettings, + ) => Promise; + applyUiThemeFromDashboardSettings: ( + settings: DashboardDisplaySettings, + ) => void; + isInteractive: () => boolean; + getDashboardSettingsPath: () => string; + writeLine: (message: string) => void; + }, +): Promise { + return deps.configureDashboardSettingsController(currentSettings, { + loadDashboardDisplaySettings: deps.loadDashboardDisplaySettings, + promptSettings: deps.promptSettings, + settingsEqual: deps.settingsEqual, + persistSelection: deps.persistSelection, + applyUiThemeFromDashboardSettings: deps.applyUiThemeFromDashboardSettings, + isInteractive: deps.isInteractive, + getDashboardSettingsPath: deps.getDashboardSettingsPath, + writeLine: deps.writeLine, + }); +} diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index b763da39..21b31ac4 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -63,6 +63,7 @@ import { cloneDashboardSettingsData, dashboardSettingsDataEqual, } from "./dashboard-settings-data.js"; +import { configureDashboardSettingsEntry } from "./dashboard-settings-entry.js"; import { promptExperimentalSettingsMenu } from "./experimental-settings-prompt.js"; import { getExperimentalSelectOptions, @@ -487,7 +488,8 @@ async function promptDashboardDisplaySettings( async function configureDashboardDisplaySettings( currentSettings?: DashboardDisplaySettings, ): Promise { - return configureDashboardSettingsController(currentSettings, { + return configureDashboardSettingsEntry(currentSettings, { + configureDashboardSettingsController, loadDashboardDisplaySettings, promptSettings: promptDashboardDisplaySettings, settingsEqual: dashboardSettingsEqual, @@ -526,7 +528,8 @@ async function promptStatuslineSettings( async function configureStatuslineSettings( currentSettings?: DashboardDisplaySettings, ): Promise { - return configureDashboardSettingsController(currentSettings, { + return configureDashboardSettingsEntry(currentSettings, { + configureDashboardSettingsController, loadDashboardDisplaySettings, promptSettings: promptStatuslineSettings, settingsEqual: dashboardSettingsEqual, diff --git a/test/dashboard-settings-entry.test.ts b/test/dashboard-settings-entry.test.ts new file mode 100644 index 00000000..6e3d7b0d --- /dev/null +++ b/test/dashboard-settings-entry.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from "vitest"; +import { configureDashboardSettingsEntry } from "../lib/codex-manager/dashboard-settings-entry.js"; + +describe("dashboard settings entry", () => { + it("delegates to dashboard settings controller with provided deps", async () => { + const configureDashboardSettingsController = vi.fn(async () => ({ + menuShowStatusBadge: false, + })); + const result = await configureDashboardSettingsEntry(undefined, { + configureDashboardSettingsController, + loadDashboardDisplaySettings: vi.fn(async () => ({ + menuShowStatusBadge: true, + })), + promptSettings: vi.fn(), + settingsEqual: vi.fn(() => false), + persistSelection: vi.fn(), + applyUiThemeFromDashboardSettings: vi.fn(), + isInteractive: vi.fn(() => true), + getDashboardSettingsPath: vi.fn(() => "/tmp/settings.json"), + writeLine: vi.fn(), + }); + + expect(configureDashboardSettingsController).toHaveBeenCalled(); + expect(result).toEqual({ menuShowStatusBadge: false }); + }); +}); From ce5f91036c2dbb96bff7cd5db4c9224edd18fe6e Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 06:32:20 +0800 Subject: [PATCH 247/376] refactor: extract live sync wrapper --- index.ts | 11 ++++--- lib/runtime/live-sync-entry.ts | 52 ++++++++++++++++++++++++++++++++++ test/live-sync-entry.test.ts | 38 +++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 lib/runtime/live-sync-entry.ts create mode 100644 test/live-sync-entry.test.ts diff --git a/index.ts b/index.ts index 8ae86526..37c14288 100644 --- a/index.ts +++ b/index.ts @@ -209,6 +209,7 @@ import { } from "./lib/runtime/account-status.js"; import { runBrowserOAuthFlow } from "./lib/runtime/browser-oauth-flow.js"; import { handleRuntimeEvent } from "./lib/runtime/event-handler.js"; +import { ensureLiveAccountSyncEntry } from "./lib/runtime/live-sync-entry.js"; import { buildManualOAuthFlow } from "./lib/runtime/manual-oauth-flow.js"; import { applyPreemptiveQuotaSettingsFromConfig, @@ -560,12 +561,13 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { pluginConfig: ReturnType, authFallback?: OAuthAuthDetails, ): Promise => { - const next = await ensureLiveAccountSyncState({ - enabled: getLiveAccountSync(pluginConfig), - targetPath: getStoragePath(), + const next = await ensureLiveAccountSyncEntry({ + pluginConfig, + authFallback, currentSync: liveAccountSync, currentPath: liveAccountSyncPath, - authFallback, + getLiveAccountSync, + getStoragePath, createSync: (oauthFallback) => new LiveAccountSync( async () => { @@ -579,6 +581,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { registerCleanup, logWarn, pluginName: PLUGIN_NAME, + ensureLiveAccountSyncState, }); liveAccountSync = next.liveAccountSync; liveAccountSyncPath = next.liveAccountSyncPath; diff --git a/lib/runtime/live-sync-entry.ts b/lib/runtime/live-sync-entry.ts new file mode 100644 index 00000000..fb368d09 --- /dev/null +++ b/lib/runtime/live-sync-entry.ts @@ -0,0 +1,52 @@ +import type { OAuthAuthDetails } from "../types.js"; + +type LiveAccountSyncLike = { + stop: () => void; + syncToPath: (path: string) => Promise; +}; + +export async function ensureLiveAccountSyncEntry< + TSync extends LiveAccountSyncLike, +>(params: { + pluginConfig: ReturnType; + authFallback?: OAuthAuthDetails; + currentSync: TSync | null; + currentPath: string | null; + getLiveAccountSync: ( + config: ReturnType, + ) => boolean; + getStoragePath: () => string; + createSync: (authFallback?: OAuthAuthDetails) => TSync; + registerCleanup: (cleanup: () => void) => void; + logWarn: (message: string) => void; + pluginName: string; + ensureLiveAccountSyncState: (args: { + enabled: boolean; + targetPath: string; + currentSync: TSync | null; + currentPath: string | null; + authFallback?: OAuthAuthDetails; + createSync: (authFallback?: OAuthAuthDetails) => TSync; + registerCleanup: (cleanup: () => void) => void; + logWarn: (message: string) => void; + pluginName: string; + }) => Promise<{ + liveAccountSync: TSync | null; + liveAccountSyncPath: string | null; + }>; +}): Promise<{ + liveAccountSync: TSync | null; + liveAccountSyncPath: string | null; +}> { + return params.ensureLiveAccountSyncState({ + enabled: params.getLiveAccountSync(params.pluginConfig), + targetPath: params.getStoragePath(), + currentSync: params.currentSync, + currentPath: params.currentPath, + authFallback: params.authFallback, + createSync: params.createSync, + registerCleanup: params.registerCleanup, + logWarn: params.logWarn, + pluginName: params.pluginName, + }); +} diff --git a/test/live-sync-entry.test.ts b/test/live-sync-entry.test.ts new file mode 100644 index 00000000..60366734 --- /dev/null +++ b/test/live-sync-entry.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from "vitest"; +import { ensureLiveAccountSyncEntry } from "../lib/runtime/live-sync-entry.js"; + +describe("live sync entry", () => { + it("delegates plugin-config-derived arguments into the live sync state helper", async () => { + const ensureLiveAccountSyncState = vi.fn(async () => ({ + liveAccountSync: { stop: vi.fn(), syncToPath: vi.fn() }, + liveAccountSyncPath: "/tmp/accounts.json", + })); + + const result = await ensureLiveAccountSyncEntry({ + pluginConfig: {} as never, + authFallback: { + type: "oauth", + accessToken: "a", + refreshToken: "r", + } as never, + currentSync: null, + currentPath: null, + getLiveAccountSync: () => true, + getStoragePath: () => "/tmp/accounts.json", + createSync: vi.fn(() => ({ stop: vi.fn(), syncToPath: vi.fn() })), + registerCleanup: vi.fn(), + logWarn: vi.fn(), + pluginName: "plugin", + ensureLiveAccountSyncState, + }); + + expect(ensureLiveAccountSyncState).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: true, + targetPath: "/tmp/accounts.json", + pluginName: "plugin", + }), + ); + expect(result.liveAccountSyncPath).toBe("/tmp/accounts.json"); + }); +}); From 811a88274b461e306f75ec2c351972681cb5c65c Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 06:40:47 +0800 Subject: [PATCH 248/376] refactor: extract flagged storage entry wrappers --- lib/storage.ts | 4 ++++ lib/storage/flagged-entry.ts | 34 +++++++++++++++++++++++++++++++++ test/flagged-entry.test.ts | 37 ++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 lib/storage/flagged-entry.ts create mode 100644 test/flagged-entry.test.ts diff --git a/lib/storage.ts b/lib/storage.ts index 1e0b976c..485ae7ae 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -31,6 +31,10 @@ import { } from "./storage/backup-paths.js"; import { restoreAccountsFromBackupPath } from "./storage/backup-restore.js"; import { looksLikeSyntheticFixtureStorage } from "./storage/fixture-guards.js"; +import { + clearFlaggedAccountsEntry, + saveFlaggedAccountsEntry, +} from "./storage/flagged-entry.js"; import { normalizeFlaggedStorage } from "./storage/flagged-storage.js"; import { clearFlaggedAccountsOnDisk, diff --git a/lib/storage/flagged-entry.ts b/lib/storage/flagged-entry.ts new file mode 100644 index 00000000..a06b9106 --- /dev/null +++ b/lib/storage/flagged-entry.ts @@ -0,0 +1,34 @@ +import type { FlaggedAccountStorageV1 } from "../storage.js"; + +export async function saveFlaggedAccountsEntry(params: { + storage: FlaggedAccountStorageV1; + withStorageLock: (fn: () => Promise) => Promise; + saveUnlocked: (storage: FlaggedAccountStorageV1) => Promise; +}): Promise { + return params.withStorageLock(async () => { + await params.saveUnlocked(params.storage); + }); +} + +export async function clearFlaggedAccountsEntry(params: { + path: string; + withStorageLock: (fn: () => Promise) => Promise; + markerPath: string; + getBackupPaths: () => Promise; + clearFlaggedAccountsOnDisk: (args: { + path: string; + markerPath: string; + backupPaths: string[]; + logError: (message: string, details: Record) => void; + }) => Promise; + logError: (message: string, details: Record) => void; +}): Promise { + return params.withStorageLock(async () => { + await params.clearFlaggedAccountsOnDisk({ + path: params.path, + markerPath: params.markerPath, + backupPaths: await params.getBackupPaths(), + logError: params.logError, + }); + }); +} diff --git a/test/flagged-entry.test.ts b/test/flagged-entry.test.ts new file mode 100644 index 00000000..259c977a --- /dev/null +++ b/test/flagged-entry.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; +import { + clearFlaggedAccountsEntry, + saveFlaggedAccountsEntry, +} from "../lib/storage/flagged-entry.js"; + +describe("flagged entry helpers", () => { + it("delegates save through the storage lock", async () => { + const saveUnlocked = vi.fn(async () => undefined); + await saveFlaggedAccountsEntry({ + storage: { version: 1, accounts: [] }, + withStorageLock: async (fn) => fn(), + saveUnlocked, + }); + + expect(saveUnlocked).toHaveBeenCalledWith({ version: 1, accounts: [] }); + }); + + it("delegates clear through the storage lock and backup resolver", async () => { + const clearFlaggedAccountsOnDisk = vi.fn(async () => undefined); + await clearFlaggedAccountsEntry({ + path: "/tmp/flagged.json", + withStorageLock: async (fn) => fn(), + markerPath: "/tmp/flagged.marker", + getBackupPaths: async () => ["/tmp/flagged.json.bak"], + clearFlaggedAccountsOnDisk, + logError: vi.fn(), + }); + + expect(clearFlaggedAccountsOnDisk).toHaveBeenCalledWith({ + path: "/tmp/flagged.json", + markerPath: "/tmp/flagged.marker", + backupPaths: ["/tmp/flagged.json.bak"], + logError: expect.any(Function), + }); + }); +}); From 60cabf8de1ce4b328073c32127cbb595d332d80e Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 06:46:22 +0800 Subject: [PATCH 249/376] refactor: extract experimental sync target wrapper --- .../experimental-sync-target-entry.ts | 64 +++++++++++++++++++ lib/codex-manager/settings-hub.ts | 20 ++---- test/experimental-sync-target-entry.test.ts | 27 ++++++++ 3 files changed, 96 insertions(+), 15 deletions(-) create mode 100644 lib/codex-manager/experimental-sync-target-entry.ts create mode 100644 test/experimental-sync-target-entry.test.ts diff --git a/lib/codex-manager/experimental-sync-target-entry.ts b/lib/codex-manager/experimental-sync-target-entry.ts new file mode 100644 index 00000000..f72201a3 --- /dev/null +++ b/lib/codex-manager/experimental-sync-target-entry.ts @@ -0,0 +1,64 @@ +import type { AccountStorageV3 } from "../storage.js"; + +export async function loadExperimentalSyncTargetEntry(params: { + loadExperimentalSyncTargetState: (args: { + detectTarget: () => ReturnType< + typeof import("../oc-chatgpt-target-detection.js").detectOcChatgptMultiAuthTarget + >; + readJson: (path: string) => Promise; + normalizeAccountStorage: (value: unknown) => AccountStorageV3 | null; + }) => Promise< + | { + kind: "blocked-ambiguous"; + detection: ReturnType< + typeof import("../oc-chatgpt-target-detection.js").detectOcChatgptMultiAuthTarget + >; + } + | { + kind: "blocked-none"; + detection: ReturnType< + typeof import("../oc-chatgpt-target-detection.js").detectOcChatgptMultiAuthTarget + >; + } + | { kind: "error"; message: string } + | { + kind: "target"; + detection: ReturnType< + typeof import("../oc-chatgpt-target-detection.js").detectOcChatgptMultiAuthTarget + >; + destination: AccountStorageV3 | null; + } + >; + detectTarget: () => ReturnType< + typeof import("../oc-chatgpt-target-detection.js").detectOcChatgptMultiAuthTarget + >; + readFileWithRetry: ( + path: string, + options: { + retryableCodes: Set; + maxAttempts: number; + sleep: (ms: number) => Promise; + }, + ) => Promise; + normalizeAccountStorage: (value: unknown) => AccountStorageV3 | null; + sleep: (ms: number) => Promise; +}): ReturnType { + return params.loadExperimentalSyncTargetState({ + detectTarget: params.detectTarget, + readJson: async (path) => + JSON.parse( + await params.readFileWithRetry(path, { + retryableCodes: new Set([ + "EBUSY", + "EPERM", + "EAGAIN", + "ENOTEMPTY", + "EACCES", + ]), + maxAttempts: 4, + sleep: params.sleep, + }), + ), + normalizeAccountStorage: params.normalizeAccountStorage, + }); +} diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index dbfface6..e3bc4565 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -70,6 +70,7 @@ import { mapExperimentalStatusHotkey, } from "./experimental-settings-schema.js"; import { loadExperimentalSyncTargetState } from "./experimental-sync-target.js"; +import { loadExperimentalSyncTargetEntry } from "./experimental-sync-target-entry.js"; import { buildSettingsHubItems, findSettingsHubInitialCursor, @@ -769,23 +770,12 @@ async function loadExperimentalSyncTarget(): Promise< destination: import("../storage.js").AccountStorageV3 | null; } > { - return loadExperimentalSyncTargetState({ + return loadExperimentalSyncTargetEntry({ + loadExperimentalSyncTargetState, detectTarget: detectOcChatgptMultiAuthTarget, - readJson: async (path) => - JSON.parse( - await readFileWithRetry(path, { - retryableCodes: new Set([ - "EBUSY", - "EPERM", - "EAGAIN", - "ENOTEMPTY", - "EACCES", - ]), - maxAttempts: 4, - sleep, - }), - ), + readFileWithRetry, normalizeAccountStorage, + sleep, }); } diff --git a/test/experimental-sync-target-entry.test.ts b/test/experimental-sync-target-entry.test.ts new file mode 100644 index 00000000..942740bb --- /dev/null +++ b/test/experimental-sync-target-entry.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vitest"; +import { loadExperimentalSyncTargetEntry } from "../lib/codex-manager/experimental-sync-target-entry.js"; + +describe("experimental sync target entry", () => { + it("delegates retrying file read and normalization through the target loader", async () => { + const loadExperimentalSyncTargetState = vi.fn(async () => ({ + kind: "target", + detection: { kind: "target" }, + destination: null, + })); + + const result = await loadExperimentalSyncTargetEntry({ + loadExperimentalSyncTargetState, + detectTarget: () => ({ kind: "target" }) as never, + readFileWithRetry: vi.fn(async () => "{}"), + normalizeAccountStorage: vi.fn(() => null), + sleep: vi.fn(async () => undefined), + }); + + expect(loadExperimentalSyncTargetState).toHaveBeenCalled(); + expect(result).toEqual({ + kind: "target", + detection: { kind: "target" }, + destination: null, + }); + }); +}); From 56323b437b278c58f44a1fe3b6869083b19c62e2 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 06:52:03 +0800 Subject: [PATCH 250/376] refactor: extract session affinity wrapper --- index.ts | 11 +++++--- lib/runtime/session-affinity-entry.ts | 38 +++++++++++++++++++++++++++ test/session-affinity-entry.test.ts | 35 ++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 lib/runtime/session-affinity-entry.ts create mode 100644 test/session-affinity-entry.test.ts diff --git a/index.ts b/index.ts index 37c14288..c17dc087 100644 --- a/index.ts +++ b/index.ts @@ -220,6 +220,7 @@ import { ensureRefreshGuardianState, ensureSessionAffinityState, } from "./lib/runtime/runtime-services.js"; +import { ensureSessionAffinityEntry } from "./lib/runtime/session-affinity-entry.js"; import { applyAccountStorageScopeFromConfig } from "./lib/runtime/storage-scope.js"; import { applyUiRuntimeFromConfig, @@ -610,14 +611,16 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const ensureSessionAffinity = ( pluginConfig: ReturnType, ): void => { - const next = ensureSessionAffinityState({ - enabled: getSessionAffinity(pluginConfig), - ttlMs: getSessionAffinityTtlMs(pluginConfig), - maxEntries: getSessionAffinityMaxEntries(pluginConfig), + const next = ensureSessionAffinityEntry({ + pluginConfig, currentStore: sessionAffinityStore, currentConfigKey: sessionAffinityConfigKey, + getSessionAffinity, + getSessionAffinityTtlMs, + getSessionAffinityMaxEntries, createStore: ({ ttlMs, maxEntries }) => new SessionAffinityStore({ ttlMs, maxEntries }), + ensureSessionAffinityState, }); sessionAffinityStore = next.sessionAffinityStore; sessionAffinityConfigKey = next.sessionAffinityConfigKey; diff --git a/lib/runtime/session-affinity-entry.ts b/lib/runtime/session-affinity-entry.ts new file mode 100644 index 00000000..3069616e --- /dev/null +++ b/lib/runtime/session-affinity-entry.ts @@ -0,0 +1,38 @@ +export function ensureSessionAffinityEntry(params: { + pluginConfig: ReturnType; + currentStore: TStore | null; + currentConfigKey: string | null; + getSessionAffinity: ( + config: ReturnType, + ) => boolean; + getSessionAffinityTtlMs: ( + config: ReturnType, + ) => number; + getSessionAffinityMaxEntries: ( + config: ReturnType, + ) => number; + createStore: (options: { ttlMs: number; maxEntries: number }) => TStore; + ensureSessionAffinityState: (args: { + enabled: boolean; + ttlMs: number; + maxEntries: number; + currentStore: TStore | null; + currentConfigKey: string | null; + createStore: (options: { ttlMs: number; maxEntries: number }) => TStore; + }) => { + sessionAffinityStore: TStore | null; + sessionAffinityConfigKey: string | null; + }; +}): { + sessionAffinityStore: TStore | null; + sessionAffinityConfigKey: string | null; +} { + return params.ensureSessionAffinityState({ + enabled: params.getSessionAffinity(params.pluginConfig), + ttlMs: params.getSessionAffinityTtlMs(params.pluginConfig), + maxEntries: params.getSessionAffinityMaxEntries(params.pluginConfig), + currentStore: params.currentStore, + currentConfigKey: params.currentConfigKey, + createStore: params.createStore, + }); +} diff --git a/test/session-affinity-entry.test.ts b/test/session-affinity-entry.test.ts new file mode 100644 index 00000000..e046bd01 --- /dev/null +++ b/test/session-affinity-entry.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from "vitest"; +import { ensureSessionAffinityEntry } from "../lib/runtime/session-affinity-entry.js"; + +describe("session affinity entry", () => { + it("delegates config-derived arguments into the session affinity state helper", () => { + const ensureSessionAffinityState = vi.fn(() => ({ + sessionAffinityStore: { id: 1 }, + sessionAffinityConfigKey: "1000:10", + })); + + const result = ensureSessionAffinityEntry({ + pluginConfig: {} as never, + currentStore: null, + currentConfigKey: null, + getSessionAffinity: () => true, + getSessionAffinityTtlMs: () => 1000, + getSessionAffinityMaxEntries: () => 10, + createStore: vi.fn(() => ({ id: 1 })), + ensureSessionAffinityState, + }); + + expect(ensureSessionAffinityState).toHaveBeenCalledWith({ + enabled: true, + ttlMs: 1000, + maxEntries: 10, + currentStore: null, + currentConfigKey: null, + createStore: expect.any(Function), + }); + expect(result).toEqual({ + sessionAffinityStore: { id: 1 }, + sessionAffinityConfigKey: "1000:10", + }); + }); +}); From 5b7e95df862ed558923e98ccac6a6f616ce0f60e Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 06:55:42 +0800 Subject: [PATCH 251/376] refactor: extract refresh guardian wrapper --- index.ts | 11 ++++--- lib/runtime/refresh-guardian-entry.ts | 47 +++++++++++++++++++++++++++ test/refresh-guardian-entry.test.ts | 37 +++++++++++++++++++++ 3 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 lib/runtime/refresh-guardian-entry.ts create mode 100644 test/refresh-guardian-entry.test.ts diff --git a/index.ts b/index.ts index c17dc087..fba9c6a3 100644 --- a/index.ts +++ b/index.ts @@ -215,6 +215,7 @@ import { applyPreemptiveQuotaSettingsFromConfig, resolveUiRuntimeFromConfig, } from "./lib/runtime/quota-settings.js"; +import { ensureRefreshGuardianEntry } from "./lib/runtime/refresh-guardian-entry.js"; import { ensureLiveAccountSyncState, ensureRefreshGuardianState, @@ -591,18 +592,20 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const ensureRefreshGuardian = ( pluginConfig: ReturnType, ): void => { - const next = ensureRefreshGuardianState({ - enabled: getProactiveRefreshGuardian(pluginConfig), - intervalMs: getProactiveRefreshIntervalMs(pluginConfig), - bufferMs: getProactiveRefreshBufferMs(pluginConfig), + const next = ensureRefreshGuardianEntry({ + pluginConfig, currentGuardian: refreshGuardian, currentConfigKey: refreshGuardianConfigKey, + getProactiveRefreshGuardian, + getProactiveRefreshIntervalMs, + getProactiveRefreshBufferMs, createGuardian: ({ intervalMs, bufferMs }) => new RefreshGuardian(() => cachedAccountManager, { intervalMs, bufferMs, }), registerCleanup, + ensureRefreshGuardianState, }); refreshGuardian = next.refreshGuardian; refreshGuardianConfigKey = next.refreshGuardianConfigKey; diff --git a/lib/runtime/refresh-guardian-entry.ts b/lib/runtime/refresh-guardian-entry.ts new file mode 100644 index 00000000..af2c6e80 --- /dev/null +++ b/lib/runtime/refresh-guardian-entry.ts @@ -0,0 +1,47 @@ +export function ensureRefreshGuardianEntry(params: { + pluginConfig: ReturnType; + currentGuardian: TGuardian | null; + currentConfigKey: string | null; + getProactiveRefreshGuardian: ( + config: ReturnType, + ) => boolean; + getProactiveRefreshIntervalMs: ( + config: ReturnType, + ) => number; + getProactiveRefreshBufferMs: ( + config: ReturnType, + ) => number; + createGuardian: (options: { + intervalMs: number; + bufferMs: number; + }) => TGuardian; + registerCleanup: (cleanup: () => void) => void; + ensureRefreshGuardianState: (args: { + enabled: boolean; + intervalMs: number; + bufferMs: number; + currentGuardian: TGuardian | null; + currentConfigKey: string | null; + createGuardian: (options: { + intervalMs: number; + bufferMs: number; + }) => TGuardian; + registerCleanup: (cleanup: () => void) => void; + }) => { + refreshGuardian: TGuardian | null; + refreshGuardianConfigKey: string | null; + }; +}): { + refreshGuardian: TGuardian | null; + refreshGuardianConfigKey: string | null; +} { + return params.ensureRefreshGuardianState({ + enabled: params.getProactiveRefreshGuardian(params.pluginConfig), + intervalMs: params.getProactiveRefreshIntervalMs(params.pluginConfig), + bufferMs: params.getProactiveRefreshBufferMs(params.pluginConfig), + currentGuardian: params.currentGuardian, + currentConfigKey: params.currentConfigKey, + createGuardian: params.createGuardian, + registerCleanup: params.registerCleanup, + }); +} diff --git a/test/refresh-guardian-entry.test.ts b/test/refresh-guardian-entry.test.ts new file mode 100644 index 00000000..d72e7654 --- /dev/null +++ b/test/refresh-guardian-entry.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; +import { ensureRefreshGuardianEntry } from "../lib/runtime/refresh-guardian-entry.js"; + +describe("refresh guardian entry", () => { + it("delegates config-derived arguments into the refresh guardian state helper", () => { + const ensureRefreshGuardianState = vi.fn(() => ({ + refreshGuardian: { id: 1 }, + refreshGuardianConfigKey: "1000:100", + })); + + const result = ensureRefreshGuardianEntry({ + pluginConfig: {} as never, + currentGuardian: null, + currentConfigKey: null, + getProactiveRefreshGuardian: () => true, + getProactiveRefreshIntervalMs: () => 1000, + getProactiveRefreshBufferMs: () => 100, + createGuardian: vi.fn(() => ({ id: 1 })), + registerCleanup: vi.fn(), + ensureRefreshGuardianState, + }); + + expect(ensureRefreshGuardianState).toHaveBeenCalledWith({ + enabled: true, + intervalMs: 1000, + bufferMs: 100, + currentGuardian: null, + currentConfigKey: null, + createGuardian: expect.any(Function), + registerCleanup: expect.any(Function), + }); + expect(result).toEqual({ + refreshGuardian: { id: 1 }, + refreshGuardianConfigKey: "1000:100", + }); + }); +}); From b027310eb9325611746ed6251738819b4bd23cd4 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 07:03:23 +0800 Subject: [PATCH 252/376] refactor: extract backend settings menu wrapper --- lib/codex-manager/backend-settings-prompt.ts | 201 +++++++++++++++++++ lib/codex-manager/settings-hub.ts | 149 ++------------ test/backend-settings-prompt.test.ts | 69 +++++++ 3 files changed, 287 insertions(+), 132 deletions(-) create mode 100644 lib/codex-manager/backend-settings-prompt.ts create mode 100644 test/backend-settings-prompt.test.ts diff --git a/lib/codex-manager/backend-settings-prompt.ts b/lib/codex-manager/backend-settings-prompt.ts new file mode 100644 index 00000000..336940cf --- /dev/null +++ b/lib/codex-manager/backend-settings-prompt.ts @@ -0,0 +1,201 @@ +import type { PluginConfig } from "../types.js"; +import type { UiRuntimeOptions } from "../ui/runtime.js"; +import type { MenuItem } from "../ui/select.js"; +import type { + BackendCategoryKey, + BackendCategoryOption, + BackendSettingFocusKey, + BackendSettingsHubAction, +} from "./backend-settings-schema.js"; + +export async function promptBackendSettingsMenu(params: { + initial: PluginConfig; + isInteractive: () => boolean; + ui: UiRuntimeOptions; + cloneBackendPluginConfig: (config: PluginConfig) => PluginConfig; + backendCategoryOptions: readonly BackendCategoryOption[]; + getBackendCategoryInitialFocus: ( + category: BackendCategoryOption, + ) => BackendSettingFocusKey; + buildBackendSettingsPreview: ( + config: PluginConfig, + ui: UiRuntimeOptions, + focus: BackendSettingFocusKey, + deps: { + highlightPreviewToken: (text: string, ui: UiRuntimeOptions) => string; + }, + ) => { label: string; hint: string }; + highlightPreviewToken: (text: string, ui: UiRuntimeOptions) => string; + select: ( + items: MenuItem[], + options: { + message: string; + subtitle: string; + help: string; + clearScreen: boolean; + theme: UiRuntimeOptions["theme"]; + selectedEmphasis: "minimal"; + initialCursor?: number; + onCursorChange: (event: { cursor: number }) => void; + onInput: (raw: string) => T | undefined; + }, + ) => Promise; + getBackendCategory: ( + key: BackendCategoryKey, + categories: readonly BackendCategoryOption[], + ) => BackendCategoryOption | null; + promptBackendCategorySettings: ( + initial: PluginConfig, + category: BackendCategoryOption, + focus: BackendSettingFocusKey, + ) => Promise<{ draft: PluginConfig; focusKey: BackendSettingFocusKey }>; + backendDefaults: PluginConfig; + copy: { + previewHeading: string; + backendCategoriesHeading: string; + resetDefault: string; + saveAndBack: string; + backNoSave: string; + backendTitle: string; + backendSubtitle: string; + backendHelp: string; + }; +}): Promise { + if (!params.isInteractive()) return null; + + let draft = params.cloneBackendPluginConfig(params.initial); + let activeCategory = params.backendCategoryOptions[0]?.key ?? "session-sync"; + const focusByCategory: Partial< + Record + > = {}; + for (const category of params.backendCategoryOptions) { + focusByCategory[category.key] = + params.getBackendCategoryInitialFocus(category); + } + + while (true) { + const previewFocus = focusByCategory[activeCategory] ?? null; + const preview = params.buildBackendSettingsPreview( + draft, + params.ui, + previewFocus, + { + highlightPreviewToken: params.highlightPreviewToken, + }, + ); + const categoryItems: MenuItem[] = + params.backendCategoryOptions.map((category, index) => ({ + label: `${index + 1}. ${category.label}`, + hint: category.description, + value: { type: "open-category", key: category.key }, + color: "green", + })); + + const items: MenuItem[] = [ + { + label: params.copy.previewHeading, + value: { type: "cancel" }, + kind: "heading", + }, + { + label: preview.label, + hint: preview.hint, + value: { type: "cancel" }, + disabled: true, + color: "green", + hideUnavailableSuffix: true, + }, + { label: "", value: { type: "cancel" }, separator: true }, + { + label: params.copy.backendCategoriesHeading, + value: { type: "cancel" }, + kind: "heading", + }, + ...categoryItems, + { label: "", value: { type: "cancel" }, separator: true }, + { + label: params.copy.resetDefault, + value: { type: "reset" }, + color: "yellow", + }, + { + label: params.copy.saveAndBack, + value: { type: "save" }, + color: "green", + }, + { + label: params.copy.backNoSave, + value: { type: "cancel" }, + color: "red", + }, + ]; + + const initialCursor = items.findIndex((item) => { + if (item.separator || item.disabled || item.kind === "heading") + return false; + return ( + item.value.type === "open-category" && item.value.key === activeCategory + ); + }); + + const result = await params.select(items, { + message: params.copy.backendTitle, + subtitle: params.copy.backendSubtitle, + help: params.copy.backendHelp, + clearScreen: true, + theme: params.ui.theme, + selectedEmphasis: "minimal", + initialCursor: initialCursor >= 0 ? initialCursor : undefined, + onCursorChange: ({ cursor }) => { + const focusedItem = items[cursor]; + if (focusedItem?.value.type === "open-category") { + activeCategory = focusedItem.value.key; + } + }, + onInput: (raw) => { + const lower = raw.toLowerCase(); + if (lower === "q") return { type: "cancel" as const }; + if (lower === "s") return { type: "save" as const }; + if (lower === "r") return { type: "reset" as const }; + const parsed = Number.parseInt(raw, 10); + if ( + Number.isFinite(parsed) && + parsed >= 1 && + parsed <= params.backendCategoryOptions.length + ) { + const target = params.backendCategoryOptions[parsed - 1]; + if (target) + return { type: "open-category" as const, key: target.key }; + } + return undefined; + }, + }); + + if (!result || result.type === "cancel") return null; + if (result.type === "save") return draft; + if (result.type === "reset") { + draft = params.cloneBackendPluginConfig(params.backendDefaults); + for (const category of params.backendCategoryOptions) { + focusByCategory[category.key] = + params.getBackendCategoryInitialFocus(category); + } + activeCategory = params.backendCategoryOptions[0]?.key ?? activeCategory; + continue; + } + + const category = params.getBackendCategory( + result.key, + params.backendCategoryOptions, + ); + if (!category) continue; + activeCategory = category.key; + const categoryResult = await params.promptBackendCategorySettings( + draft, + category, + focusByCategory[category.key] ?? + params.getBackendCategoryInitialFocus(category), + ); + draft = categoryResult.draft; + focusByCategory[category.key] = categoryResult.focusKey; + } +} diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index dbfface6..9fa00128 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -20,7 +20,7 @@ import { loadAccounts, normalizeAccountStorage } from "../storage.js"; import type { PluginConfig } from "../types.js"; import { UI_COPY } from "../ui/copy.js"; import { getUiRuntimeOptions, setUiRuntimeOptions } from "../ui/runtime.js"; -import { type MenuItem, select } from "../ui/select.js"; +import { select } from "../ui/select.js"; import { sleep } from "../utils.js"; import { applyBackendCategoryDefaults, @@ -39,16 +39,15 @@ import { cloneBackendPluginConfig, formatBackendNumberValue, } from "./backend-settings-helpers.js"; +import { promptBackendSettingsMenu } from "./backend-settings-prompt.js"; import { BACKEND_CATEGORY_OPTIONS, BACKEND_DEFAULTS, BACKEND_NUMBER_OPTION_BY_KEY, BACKEND_TOGGLE_OPTION_BY_KEY, - type BackendCategoryKey, type BackendCategoryOption, type BackendNumberSettingOption, type BackendSettingFocusKey, - type BackendSettingsHubAction, } from "./backend-settings-schema.js"; import { promptBehaviorSettingsPanel } from "./behavior-settings-panel.js"; import { promptDashboardDisplayPanel } from "./dashboard-display-panel.js"; @@ -622,135 +621,21 @@ async function promptBackendCategorySettings( async function promptBackendSettings( initial: PluginConfig, ): Promise { - if (!input.isTTY || !output.isTTY) return null; - - const ui = getUiRuntimeOptions(); - let draft = cloneBackendPluginConfig(initial); - let activeCategory = BACKEND_CATEGORY_OPTIONS[0]?.key ?? "session-sync"; - const focusByCategory: Partial< - Record - > = {}; - for (const category of BACKEND_CATEGORY_OPTIONS) { - focusByCategory[category.key] = getBackendCategoryInitialFocus(category); - } - - while (true) { - const previewFocus = focusByCategory[activeCategory] ?? null; - const preview = buildBackendSettingsPreview(draft, ui, previewFocus, { - highlightPreviewToken, - }); - const categoryItems: MenuItem[] = - BACKEND_CATEGORY_OPTIONS.map((category, index) => { - return { - label: `${index + 1}. ${category.label}`, - hint: category.description, - value: { type: "open-category", key: category.key }, - color: "green", - }; - }); - - const items: MenuItem[] = [ - { - label: UI_COPY.settings.previewHeading, - value: { type: "cancel" }, - kind: "heading", - }, - { - label: preview.label, - hint: preview.hint, - value: { type: "cancel" }, - disabled: true, - color: "green", - hideUnavailableSuffix: true, - }, - { label: "", value: { type: "cancel" }, separator: true }, - { - label: UI_COPY.settings.backendCategoriesHeading, - value: { type: "cancel" }, - kind: "heading", - }, - ...categoryItems, - { label: "", value: { type: "cancel" }, separator: true }, - { - label: UI_COPY.settings.resetDefault, - value: { type: "reset" }, - color: "yellow", - }, - { - label: UI_COPY.settings.saveAndBack, - value: { type: "save" }, - color: "green", - }, - { - label: UI_COPY.settings.backNoSave, - value: { type: "cancel" }, - color: "red", - }, - ]; - - const initialCursor = items.findIndex((item) => { - if (item.separator || item.disabled || item.kind === "heading") - return false; - return ( - item.value.type === "open-category" && item.value.key === activeCategory - ); - }); - - const result = await select(items, { - message: UI_COPY.settings.backendTitle, - subtitle: UI_COPY.settings.backendSubtitle, - help: UI_COPY.settings.backendHelp, - clearScreen: true, - theme: ui.theme, - selectedEmphasis: "minimal", - initialCursor: initialCursor >= 0 ? initialCursor : undefined, - onCursorChange: ({ cursor }) => { - const focusedItem = items[cursor]; - if (focusedItem?.value.type === "open-category") { - activeCategory = focusedItem.value.key; - } - }, - onInput: (raw) => { - const lower = raw.toLowerCase(); - if (lower === "q") return { type: "cancel" }; - if (lower === "s") return { type: "save" }; - if (lower === "r") return { type: "reset" }; - const parsed = Number.parseInt(raw, 10); - if ( - Number.isFinite(parsed) && - parsed >= 1 && - parsed <= BACKEND_CATEGORY_OPTIONS.length - ) { - const target = BACKEND_CATEGORY_OPTIONS[parsed - 1]; - if (target) return { type: "open-category", key: target.key }; - } - return undefined; - }, - }); - - if (!result || result.type === "cancel") return null; - if (result.type === "save") return draft; - if (result.type === "reset") { - draft = cloneBackendPluginConfig(BACKEND_DEFAULTS); - for (const category of BACKEND_CATEGORY_OPTIONS) { - focusByCategory[category.key] = - getBackendCategoryInitialFocus(category); - } - activeCategory = BACKEND_CATEGORY_OPTIONS[0]?.key ?? activeCategory; - continue; - } - - const category = getBackendCategory(result.key, BACKEND_CATEGORY_OPTIONS); - if (!category) continue; - activeCategory = category.key; - const categoryResult = await promptBackendCategorySettings( - draft, - category, - focusByCategory[category.key] ?? getBackendCategoryInitialFocus(category), - ); - draft = categoryResult.draft; - focusByCategory[category.key] = categoryResult.focusKey; - } + return promptBackendSettingsMenu({ + initial, + isInteractive: () => input.isTTY && output.isTTY, + ui: getUiRuntimeOptions(), + cloneBackendPluginConfig, + backendCategoryOptions: BACKEND_CATEGORY_OPTIONS, + getBackendCategoryInitialFocus, + buildBackendSettingsPreview, + highlightPreviewToken, + select, + getBackendCategory, + promptBackendCategorySettings, + backendDefaults: BACKEND_DEFAULTS, + copy: UI_COPY.settings, + }); } async function loadExperimentalSyncTarget(): Promise< diff --git a/test/backend-settings-prompt.test.ts b/test/backend-settings-prompt.test.ts new file mode 100644 index 00000000..380811fe --- /dev/null +++ b/test/backend-settings-prompt.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; +import { promptBackendSettingsMenu } from "../lib/codex-manager/backend-settings-prompt.js"; + +describe("backend settings prompt", () => { + it("returns null when not interactive", async () => { + const result = await promptBackendSettingsMenu({ + initial: { fetchTimeoutMs: 1000 }, + isInteractive: () => false, + ui: { theme: {} } as never, + cloneBackendPluginConfig: (config) => ({ ...config }), + backendCategoryOptions: [], + getBackendCategoryInitialFocus: vi.fn(), + buildBackendSettingsPreview: vi.fn(), + highlightPreviewToken: vi.fn((text) => text), + select: vi.fn(), + getBackendCategory: vi.fn(), + promptBackendCategorySettings: vi.fn(), + backendDefaults: { fetchTimeoutMs: 1000 }, + copy: { + previewHeading: "Preview", + backendCategoriesHeading: "Categories", + resetDefault: "Reset", + saveAndBack: "Save", + backNoSave: "Back", + backendTitle: "Backend", + backendSubtitle: "Subtitle", + backendHelp: "Help", + }, + }); + + expect(result).toBeNull(); + }); + + it("returns updated draft when save is chosen after reset", async () => { + const select = vi + .fn() + .mockResolvedValueOnce({ type: "reset" }) + .mockResolvedValueOnce({ type: "save" }); + + const result = await promptBackendSettingsMenu({ + initial: { fetchTimeoutMs: 5000 }, + isInteractive: () => true, + ui: { theme: {} } as never, + cloneBackendPluginConfig: (config) => ({ ...config }), + backendCategoryOptions: [ + { key: "session-sync", label: "Session Sync", description: "desc" }, + ] as never, + getBackendCategoryInitialFocus: () => null, + buildBackendSettingsPreview: () => ({ label: "Preview", hint: "Hint" }), + highlightPreviewToken: vi.fn((text) => text), + select, + getBackendCategory: vi.fn(() => null), + promptBackendCategorySettings: vi.fn(), + backendDefaults: { fetchTimeoutMs: 1000 }, + copy: { + previewHeading: "Preview", + backendCategoriesHeading: "Categories", + resetDefault: "Reset", + saveAndBack: "Save", + backNoSave: "Back", + backendTitle: "Backend", + backendSubtitle: "Subtitle", + backendHelp: "Help", + }, + }); + + expect(result).toEqual({ fetchTimeoutMs: 1000 }); + }); +}); From 534f795a9822b770dab9c26672f0aaa927d72a25 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 07:09:09 +0800 Subject: [PATCH 253/376] refactor: extract account storage scope wrapper --- index.ts | 5 ++- lib/runtime/account-storage-scope-entry.ts | 49 ++++++++++++++++++++++ test/account-storage-scope-entry.test.ts | 38 +++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 lib/runtime/account-storage-scope-entry.ts create mode 100644 test/account-storage-scope-entry.test.ts diff --git a/index.ts b/index.ts index fba9c6a3..aeb2f5f2 100644 --- a/index.ts +++ b/index.ts @@ -207,6 +207,7 @@ import { getRateLimitResetTimeForFamily, resolveActiveIndex, } from "./lib/runtime/account-status.js"; +import { applyAccountStorageScopeEntry } from "./lib/runtime/account-storage-scope-entry.js"; import { runBrowserOAuthFlow } from "./lib/runtime/browser-oauth-flow.js"; import { handleRuntimeEvent } from "./lib/runtime/event-handler.js"; import { ensureLiveAccountSyncEntry } from "./lib/runtime/live-sync-entry.js"; @@ -544,7 +545,8 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const applyAccountStorageScope = ( pluginConfig: ReturnType, ): void => - applyAccountStorageScopeFromConfig(pluginConfig, { + applyAccountStorageScopeEntry({ + pluginConfig, getPerProjectAccounts, getStorageBackupEnabled, setStorageBackupEnabled, @@ -557,6 +559,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { pluginName: PLUGIN_NAME, setStoragePath, cwd: () => process.cwd(), + applyAccountStorageScopeFromConfig, }); const ensureLiveAccountSync = async ( diff --git a/lib/runtime/account-storage-scope-entry.ts b/lib/runtime/account-storage-scope-entry.ts new file mode 100644 index 00000000..ca681d48 --- /dev/null +++ b/lib/runtime/account-storage-scope-entry.ts @@ -0,0 +1,49 @@ +export function applyAccountStorageScopeEntry(params: { + pluginConfig: ReturnType; + getPerProjectAccounts: ( + config: ReturnType, + ) => boolean; + getStorageBackupEnabled: ( + config: ReturnType, + ) => boolean; + setStorageBackupEnabled: (enabled: boolean) => void; + isCodexCliSyncEnabled: () => boolean; + getWarningShown: () => boolean; + setWarningShown: (shown: boolean) => void; + logWarn: (message: string) => void; + pluginName: string; + setStoragePath: (path: string | null) => void; + cwd: () => string; + applyAccountStorageScopeFromConfig: ( + pluginConfig: ReturnType, + deps: { + getPerProjectAccounts: ( + config: ReturnType, + ) => boolean; + getStorageBackupEnabled: ( + config: ReturnType, + ) => boolean; + setStorageBackupEnabled: (enabled: boolean) => void; + isCodexCliSyncEnabled: () => boolean; + getWarningShown: () => boolean; + setWarningShown: (shown: boolean) => void; + logWarn: (message: string) => void; + pluginName: string; + setStoragePath: (path: string | null) => void; + cwd: () => string; + }, + ) => void; +}): void { + params.applyAccountStorageScopeFromConfig(params.pluginConfig, { + getPerProjectAccounts: params.getPerProjectAccounts, + getStorageBackupEnabled: params.getStorageBackupEnabled, + setStorageBackupEnabled: params.setStorageBackupEnabled, + isCodexCliSyncEnabled: params.isCodexCliSyncEnabled, + getWarningShown: params.getWarningShown, + setWarningShown: params.setWarningShown, + logWarn: params.logWarn, + pluginName: params.pluginName, + setStoragePath: params.setStoragePath, + cwd: params.cwd, + }); +} diff --git a/test/account-storage-scope-entry.test.ts b/test/account-storage-scope-entry.test.ts new file mode 100644 index 00000000..826c9e0a --- /dev/null +++ b/test/account-storage-scope-entry.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from "vitest"; +import { applyAccountStorageScopeEntry } from "../lib/runtime/account-storage-scope-entry.js"; + +describe("account storage scope entry", () => { + it("delegates to the config-based storage scope helper with all injected deps", () => { + const applyAccountStorageScopeFromConfig = vi.fn(); + applyAccountStorageScopeEntry({ + pluginConfig: {} as never, + getPerProjectAccounts: vi.fn(() => true), + getStorageBackupEnabled: vi.fn(() => true), + setStorageBackupEnabled: vi.fn(), + isCodexCliSyncEnabled: vi.fn(() => false), + getWarningShown: vi.fn(() => false), + setWarningShown: vi.fn(), + logWarn: vi.fn(), + pluginName: "plugin", + setStoragePath: vi.fn(), + cwd: vi.fn(() => "/tmp/project"), + applyAccountStorageScopeFromConfig, + }); + + expect(applyAccountStorageScopeFromConfig).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + getPerProjectAccounts: expect.any(Function), + getStorageBackupEnabled: expect.any(Function), + setStorageBackupEnabled: expect.any(Function), + isCodexCliSyncEnabled: expect.any(Function), + getWarningShown: expect.any(Function), + setWarningShown: expect.any(Function), + logWarn: expect.any(Function), + pluginName: "plugin", + setStoragePath: expect.any(Function), + cwd: expect.any(Function), + }), + ); + }); +}); From 8e36a86a7da82f3b7bc0d0347cb979e3550d4ab0 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 07:17:12 +0800 Subject: [PATCH 254/376] refactor: extract clear accounts entry wrapper --- lib/storage.ts | 26 +++++++++++++------------- lib/storage/account-clear-entry.ts | 25 +++++++++++++++++++++++++ test/account-clear-entry.test.ts | 25 +++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 13 deletions(-) create mode 100644 lib/storage/account-clear-entry.ts create mode 100644 test/account-clear-entry.test.ts diff --git a/lib/storage.ts b/lib/storage.ts index 485ae7ae..f0552eaa 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -11,12 +11,11 @@ import { } from "./named-backup-export.js"; import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; import { clearAccountStorageArtifacts } from "./storage/account-clear.js"; +import { clearAccountsEntry } from "./storage/account-clear-entry.js"; import { cloneAccountStorageForPersistence } from "./storage/account-persistence.js"; import { - clearFlaggedAccountsEntry, exportAccountsSnapshot, importAccountsSnapshot, - saveFlaggedAccountsEntry, } from "./storage/account-port.js"; import { saveAccountsToDisk } from "./storage/account-save.js"; import { buildBackupMetadata } from "./storage/backup-metadata-builder.js"; @@ -1847,17 +1846,18 @@ export async function saveAccounts(storage: AccountStorageV3): Promise { * Silently ignores if file doesn't exist. */ export async function clearAccounts(): Promise { - return withStorageLock(async () => { - const path = getStoragePath(); - await clearAccountStorageArtifacts({ - path, - resetMarkerPath: getIntentionalResetMarkerPath(path), - walPath: getAccountsWalPath(path), - backupPaths: await getAccountsBackupRecoveryCandidatesWithDiscovery(path), - logError: (message, details) => { - log.error(message, details); - }, - }); + const path = getStoragePath(); + return clearAccountsEntry({ + path, + withStorageLock, + resetMarkerPath: getIntentionalResetMarkerPath(path), + walPath: getAccountsWalPath(path), + getBackupPaths: () => + getAccountsBackupRecoveryCandidatesWithDiscovery(path), + clearAccountStorageArtifacts, + logError: (message, details) => { + log.error(message, details); + }, }); } diff --git a/lib/storage/account-clear-entry.ts b/lib/storage/account-clear-entry.ts new file mode 100644 index 00000000..b31a531e --- /dev/null +++ b/lib/storage/account-clear-entry.ts @@ -0,0 +1,25 @@ +export async function clearAccountsEntry(params: { + path: string; + withStorageLock: (fn: () => Promise) => Promise; + resetMarkerPath: string; + walPath: string; + getBackupPaths: () => Promise; + clearAccountStorageArtifacts: (args: { + path: string; + resetMarkerPath: string; + walPath: string; + backupPaths: string[]; + logError: (message: string, details: Record) => void; + }) => Promise; + logError: (message: string, details: Record) => void; +}): Promise { + return params.withStorageLock(async () => { + await params.clearAccountStorageArtifacts({ + path: params.path, + resetMarkerPath: params.resetMarkerPath, + walPath: params.walPath, + backupPaths: await params.getBackupPaths(), + logError: params.logError, + }); + }); +} diff --git a/test/account-clear-entry.test.ts b/test/account-clear-entry.test.ts new file mode 100644 index 00000000..4981d5e7 --- /dev/null +++ b/test/account-clear-entry.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from "vitest"; +import { clearAccountsEntry } from "../lib/storage/account-clear-entry.js"; + +describe("account clear entry", () => { + it("delegates clear through the storage lock and backup resolver", async () => { + const clearAccountStorageArtifacts = vi.fn(async () => undefined); + await clearAccountsEntry({ + path: "/tmp/accounts.json", + withStorageLock: async (fn) => fn(), + resetMarkerPath: "/tmp/accounts.reset-intent", + walPath: "/tmp/accounts.wal", + getBackupPaths: async () => ["/tmp/accounts.json.bak"], + clearAccountStorageArtifacts, + logError: vi.fn(), + }); + + expect(clearAccountStorageArtifacts).toHaveBeenCalledWith({ + path: "/tmp/accounts.json", + resetMarkerPath: "/tmp/accounts.reset-intent", + walPath: "/tmp/accounts.wal", + backupPaths: ["/tmp/accounts.json.bak"], + logError: expect.any(Function), + }); + }); +}); From ac31f4745212f7f71682960b8d0172bfdf36efde Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 07:21:02 +0800 Subject: [PATCH 255/376] refactor: extract experimental target loader wrapper --- .../experimental-sync-target-entry.ts | 64 +++++++++++++++++++ lib/codex-manager/settings-hub.ts | 20 ++---- test/experimental-sync-target-entry.test.ts | 27 ++++++++ 3 files changed, 96 insertions(+), 15 deletions(-) create mode 100644 lib/codex-manager/experimental-sync-target-entry.ts create mode 100644 test/experimental-sync-target-entry.test.ts diff --git a/lib/codex-manager/experimental-sync-target-entry.ts b/lib/codex-manager/experimental-sync-target-entry.ts new file mode 100644 index 00000000..f72201a3 --- /dev/null +++ b/lib/codex-manager/experimental-sync-target-entry.ts @@ -0,0 +1,64 @@ +import type { AccountStorageV3 } from "../storage.js"; + +export async function loadExperimentalSyncTargetEntry(params: { + loadExperimentalSyncTargetState: (args: { + detectTarget: () => ReturnType< + typeof import("../oc-chatgpt-target-detection.js").detectOcChatgptMultiAuthTarget + >; + readJson: (path: string) => Promise; + normalizeAccountStorage: (value: unknown) => AccountStorageV3 | null; + }) => Promise< + | { + kind: "blocked-ambiguous"; + detection: ReturnType< + typeof import("../oc-chatgpt-target-detection.js").detectOcChatgptMultiAuthTarget + >; + } + | { + kind: "blocked-none"; + detection: ReturnType< + typeof import("../oc-chatgpt-target-detection.js").detectOcChatgptMultiAuthTarget + >; + } + | { kind: "error"; message: string } + | { + kind: "target"; + detection: ReturnType< + typeof import("../oc-chatgpt-target-detection.js").detectOcChatgptMultiAuthTarget + >; + destination: AccountStorageV3 | null; + } + >; + detectTarget: () => ReturnType< + typeof import("../oc-chatgpt-target-detection.js").detectOcChatgptMultiAuthTarget + >; + readFileWithRetry: ( + path: string, + options: { + retryableCodes: Set; + maxAttempts: number; + sleep: (ms: number) => Promise; + }, + ) => Promise; + normalizeAccountStorage: (value: unknown) => AccountStorageV3 | null; + sleep: (ms: number) => Promise; +}): ReturnType { + return params.loadExperimentalSyncTargetState({ + detectTarget: params.detectTarget, + readJson: async (path) => + JSON.parse( + await params.readFileWithRetry(path, { + retryableCodes: new Set([ + "EBUSY", + "EPERM", + "EAGAIN", + "ENOTEMPTY", + "EACCES", + ]), + maxAttempts: 4, + sleep: params.sleep, + }), + ), + normalizeAccountStorage: params.normalizeAccountStorage, + }); +} diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 9fa00128..4f3517d8 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -69,6 +69,7 @@ import { mapExperimentalStatusHotkey, } from "./experimental-settings-schema.js"; import { loadExperimentalSyncTargetState } from "./experimental-sync-target.js"; +import { loadExperimentalSyncTargetEntry } from "./experimental-sync-target-entry.js"; import { buildSettingsHubItems, findSettingsHubInitialCursor, @@ -654,23 +655,12 @@ async function loadExperimentalSyncTarget(): Promise< destination: import("../storage.js").AccountStorageV3 | null; } > { - return loadExperimentalSyncTargetState({ + return loadExperimentalSyncTargetEntry({ + loadExperimentalSyncTargetState, detectTarget: detectOcChatgptMultiAuthTarget, - readJson: async (path) => - JSON.parse( - await readFileWithRetry(path, { - retryableCodes: new Set([ - "EBUSY", - "EPERM", - "EAGAIN", - "ENOTEMPTY", - "EACCES", - ]), - maxAttempts: 4, - sleep, - }), - ), + readFileWithRetry, normalizeAccountStorage, + sleep, }); } diff --git a/test/experimental-sync-target-entry.test.ts b/test/experimental-sync-target-entry.test.ts new file mode 100644 index 00000000..942740bb --- /dev/null +++ b/test/experimental-sync-target-entry.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vitest"; +import { loadExperimentalSyncTargetEntry } from "../lib/codex-manager/experimental-sync-target-entry.js"; + +describe("experimental sync target entry", () => { + it("delegates retrying file read and normalization through the target loader", async () => { + const loadExperimentalSyncTargetState = vi.fn(async () => ({ + kind: "target", + detection: { kind: "target" }, + destination: null, + })); + + const result = await loadExperimentalSyncTargetEntry({ + loadExperimentalSyncTargetState, + detectTarget: () => ({ kind: "target" }) as never, + readFileWithRetry: vi.fn(async () => "{}"), + normalizeAccountStorage: vi.fn(() => null), + sleep: vi.fn(async () => undefined), + }); + + expect(loadExperimentalSyncTargetState).toHaveBeenCalled(); + expect(result).toEqual({ + kind: "target", + detection: { kind: "target" }, + destination: null, + }); + }); +}); From f50045ea8523233281b160f240d50143cee8f72b Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 07:27:07 +0800 Subject: [PATCH 256/376] refactor: extract clear accounts entry wrapper --- lib/storage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/storage.ts b/lib/storage.ts index f0552eaa..14efe8c2 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1855,7 +1855,7 @@ export async function clearAccounts(): Promise { getBackupPaths: () => getAccountsBackupRecoveryCandidatesWithDiscovery(path), clearAccountStorageArtifacts, - logError: (message, details) => { + logError: (message: string, details: Record) => { log.error(message, details); }, }); From 95cca7b54045cd9080021fe8106acda436f5d085 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 07:31:09 +0800 Subject: [PATCH 257/376] refactor: extract named backup facade --- lib/storage.ts | 12 ++++++------ lib/storage/named-backup-entry.ts | 31 +++++++++++++++++++++++++++++++ test/named-backup-entry.test.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 lib/storage/named-backup-entry.ts create mode 100644 test/named-backup-entry.test.ts diff --git a/lib/storage.ts b/lib/storage.ts index 14efe8c2..572ab342 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -56,6 +56,7 @@ import { migrateV1ToV3, type RateLimitStateV3, } from "./storage/migrations.js"; +import { exportNamedBackupEntry } from "./storage/named-backup-entry.js"; import { collectNamedBackups, type NamedBackupSummary, @@ -825,14 +826,13 @@ export async function exportNamedBackup( name: string, options?: { force?: boolean }, ): Promise { - return exportNamedBackupFile( + return exportNamedBackupEntry({ name, - { - getStoragePath, - exportAccounts, - }, options, - ); + exportNamedBackupFile, + getStoragePath, + exportAccounts, + }); } export function getFlaggedAccountsPath(): string { diff --git a/lib/storage/named-backup-entry.ts b/lib/storage/named-backup-entry.ts new file mode 100644 index 00000000..1a1ee421 --- /dev/null +++ b/lib/storage/named-backup-entry.ts @@ -0,0 +1,31 @@ +export async function exportNamedBackupEntry(params: { + name: string; + options?: { force?: boolean }; + exportNamedBackupFile: ( + name: string, + deps: { + getStoragePath: () => string; + exportAccounts: ( + filePath: string, + force?: boolean, + beforeCommit?: (resolvedPath: string) => Promise | void, + ) => Promise; + }, + options?: { force?: boolean }, + ) => Promise; + getStoragePath: () => string; + exportAccounts: ( + filePath: string, + force?: boolean, + beforeCommit?: (resolvedPath: string) => Promise | void, + ) => Promise; +}): Promise { + return params.exportNamedBackupFile( + params.name, + { + getStoragePath: params.getStoragePath, + exportAccounts: params.exportAccounts, + }, + params.options, + ); +} diff --git a/test/named-backup-entry.test.ts b/test/named-backup-entry.test.ts new file mode 100644 index 00000000..4c0bae3e --- /dev/null +++ b/test/named-backup-entry.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vitest"; +import { exportNamedBackupEntry } from "../lib/storage/named-backup-entry.js"; + +describe("named backup entry", () => { + it("passes name, deps, and options through to the named backup exporter", async () => { + const exportNamedBackupFile = vi.fn(async () => "/tmp/backup.json"); + const exportAccounts = vi.fn(async () => undefined); + + const result = await exportNamedBackupEntry({ + name: "manual-backup", + options: { force: true }, + exportNamedBackupFile, + getStoragePath: () => "/tmp/accounts.json", + exportAccounts, + }); + + expect(exportNamedBackupFile).toHaveBeenCalledWith( + "manual-backup", + { + getStoragePath: expect.any(Function), + exportAccounts, + }, + { force: true }, + ); + expect(result).toBe("/tmp/backup.json"); + }); +}); From 51bcf9f77b09007e2f06cdb5e70b759faf624c12 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 07:39:09 +0800 Subject: [PATCH 258/376] refactor: extract loader setup wrapper --- index.ts | 15 ++++++++++----- lib/runtime/loader-setup.ts | 24 ++++++++++++++++++++++++ test/loader-setup.test.ts | 30 ++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 lib/runtime/loader-setup.ts create mode 100644 test/loader-setup.test.ts diff --git a/index.ts b/index.ts index aeb2f5f2..60b44133 100644 --- a/index.ts +++ b/index.ts @@ -211,6 +211,7 @@ import { applyAccountStorageScopeEntry } from "./lib/runtime/account-storage-sco import { runBrowserOAuthFlow } from "./lib/runtime/browser-oauth-flow.js"; import { handleRuntimeEvent } from "./lib/runtime/event-handler.js"; import { ensureLiveAccountSyncEntry } from "./lib/runtime/live-sync-entry.js"; +import { applyLoaderRuntimeSetup } from "./lib/runtime/loader-setup.js"; import { buildManualOAuthFlow } from "./lib/runtime/manual-oauth-flow.js"; import { applyPreemptiveQuotaSettingsFromConfig, @@ -691,11 +692,15 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { async loader(getAuth: () => Promise, provider: unknown) { const auth = await getAuth(); const pluginConfig = loadPluginConfig(); - applyUiRuntimeFromConfig(pluginConfig, setUiRuntimeOptions); - applyAccountStorageScope(pluginConfig); - ensureSessionAffinity(pluginConfig); - ensureRefreshGuardian(pluginConfig); - applyPreemptiveQuotaSettings(pluginConfig); + applyLoaderRuntimeSetup({ + pluginConfig, + applyUiRuntimeFromConfig: (config) => + applyUiRuntimeFromConfig(config, setUiRuntimeOptions), + applyAccountStorageScope, + ensureSessionAffinity, + ensureRefreshGuardian, + applyPreemptiveQuotaSettings, + }); // Only handle OAuth auth type, skip API key auth if (auth.type !== "oauth") { diff --git a/lib/runtime/loader-setup.ts b/lib/runtime/loader-setup.ts new file mode 100644 index 00000000..60c26599 --- /dev/null +++ b/lib/runtime/loader-setup.ts @@ -0,0 +1,24 @@ +export function applyLoaderRuntimeSetup(params: { + pluginConfig: ReturnType; + applyUiRuntimeFromConfig: ( + pluginConfig: ReturnType, + ) => void; + applyAccountStorageScope: ( + pluginConfig: ReturnType, + ) => void; + ensureSessionAffinity: ( + pluginConfig: ReturnType, + ) => void; + ensureRefreshGuardian: ( + pluginConfig: ReturnType, + ) => void; + applyPreemptiveQuotaSettings: ( + pluginConfig: ReturnType, + ) => void; +}): void { + params.applyUiRuntimeFromConfig(params.pluginConfig); + params.applyAccountStorageScope(params.pluginConfig); + params.ensureSessionAffinity(params.pluginConfig); + params.ensureRefreshGuardian(params.pluginConfig); + params.applyPreemptiveQuotaSettings(params.pluginConfig); +} diff --git a/test/loader-setup.test.ts b/test/loader-setup.test.ts new file mode 100644 index 00000000..6fb43667 --- /dev/null +++ b/test/loader-setup.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from "vitest"; +import { applyLoaderRuntimeSetup } from "../lib/runtime/loader-setup.js"; + +describe("loader runtime setup", () => { + it("applies runtime setup steps in the existing order", () => { + const calls: string[] = []; + const pluginConfig = { a: 1 } as never; + + applyLoaderRuntimeSetup({ + pluginConfig, + applyUiRuntimeFromConfig: () => { + calls.push("ui"); + }, + applyAccountStorageScope: () => { + calls.push("scope"); + }, + ensureSessionAffinity: () => { + calls.push("session"); + }, + ensureRefreshGuardian: () => { + calls.push("guardian"); + }, + applyPreemptiveQuotaSettings: () => { + calls.push("quota"); + }, + }); + + expect(calls).toEqual(["ui", "scope", "session", "guardian", "quota"]); + }); +}); From f95fb61f89db0b8ebf95ea8435aac90ae36c1414 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 07:45:21 +0800 Subject: [PATCH 259/376] refactor: extract save accounts entry wrapper --- lib/storage.ts | 7 +++++-- lib/storage/account-save-entry.ts | 11 +++++++++++ test/account-save-entry.test.ts | 25 +++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 lib/storage/account-save-entry.ts create mode 100644 test/account-save-entry.test.ts diff --git a/lib/storage.ts b/lib/storage.ts index 572ab342..516354fe 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -18,6 +18,7 @@ import { importAccountsSnapshot, } from "./storage/account-port.js"; import { saveAccountsToDisk } from "./storage/account-save.js"; +import { saveAccountsEntry } from "./storage/account-save-entry.js"; import { buildBackupMetadata } from "./storage/backup-metadata-builder.js"; import { ACCOUNTS_BACKUP_SUFFIX, @@ -1836,8 +1837,10 @@ export async function withAccountAndFlaggedStorageTransaction( * @throws StorageError with platform-aware hints on failure */ export async function saveAccounts(storage: AccountStorageV3): Promise { - return withStorageLock(async () => { - await saveAccountsUnlocked(storage); + return saveAccountsEntry({ + storage, + withStorageLock, + saveUnlocked: saveAccountsUnlocked, }); } diff --git a/lib/storage/account-save-entry.ts b/lib/storage/account-save-entry.ts new file mode 100644 index 00000000..6871a78a --- /dev/null +++ b/lib/storage/account-save-entry.ts @@ -0,0 +1,11 @@ +import type { AccountStorageV3 } from "../storage.js"; + +export async function saveAccountsEntry(params: { + storage: AccountStorageV3; + withStorageLock: (fn: () => Promise) => Promise; + saveUnlocked: (storage: AccountStorageV3) => Promise; +}): Promise { + return params.withStorageLock(async () => { + await params.saveUnlocked(params.storage); + }); +} diff --git a/test/account-save-entry.test.ts b/test/account-save-entry.test.ts new file mode 100644 index 00000000..b5515f1b --- /dev/null +++ b/test/account-save-entry.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from "vitest"; +import { saveAccountsEntry } from "../lib/storage/account-save-entry.js"; + +describe("account save entry", () => { + it("delegates save through the storage lock", async () => { + const saveUnlocked = vi.fn(async () => undefined); + await saveAccountsEntry({ + storage: { + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }, + withStorageLock: async (fn) => fn(), + saveUnlocked, + }); + + expect(saveUnlocked).toHaveBeenCalledWith({ + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }); + }); +}); From 401f21c2ebd3517af9b7565c6064914850fda0b2 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 07:52:46 +0800 Subject: [PATCH 260/376] refactor: extract restore backup facade --- lib/storage.ts | 10 ++++--- lib/storage/restore-backup-entry.ts | 33 +++++++++++++++++++++++ test/restore-backup-entry.test.ts | 42 +++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 lib/storage/restore-backup-entry.ts create mode 100644 test/restore-backup-entry.test.ts diff --git a/lib/storage.ts b/lib/storage.ts index 516354fe..4ef34620 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -75,6 +75,7 @@ import { mergeStorageForMigration, } from "./storage/project-migration.js"; import { buildRestoreAssessment } from "./storage/restore-assessment.js"; +import { restoreAccountsFromBackupEntry } from "./storage/restore-backup-entry.js"; import { loadAccountsFromPath, parseAndNormalizeStorage, @@ -810,9 +811,12 @@ export async function restoreAccountsFromBackup( path: string, options?: { persist?: boolean }, ): Promise { - return restoreAccountsFromBackupPath(path, { - persist: options?.persist, - backupRoot: getNamedBackupRoot(getStoragePath()), + return restoreAccountsFromBackupEntry({ + path, + options, + restoreAccountsFromBackupPath, + getNamedBackupRoot, + getStoragePath, realpath: fs.realpath, loadAccountsFromPath: (path) => loadAccountsFromPath(path, { diff --git a/lib/storage/restore-backup-entry.ts b/lib/storage/restore-backup-entry.ts new file mode 100644 index 00000000..898c2c70 --- /dev/null +++ b/lib/storage/restore-backup-entry.ts @@ -0,0 +1,33 @@ +import type { AccountStorageV3 } from "../storage.js"; + +export async function restoreAccountsFromBackupEntry(params: { + path: string; + options?: { persist?: boolean }; + restoreAccountsFromBackupPath: ( + path: string, + options: { + persist?: boolean; + backupRoot: string; + realpath: (path: string) => Promise; + loadAccountsFromPath: (path: string) => Promise<{ + normalized: AccountStorageV3 | null; + }>; + saveAccounts: (storage: AccountStorageV3) => Promise; + }, + ) => Promise; + getNamedBackupRoot: (storagePath: string) => string; + getStoragePath: () => string; + realpath: (path: string) => Promise; + loadAccountsFromPath: ( + path: string, + ) => Promise<{ normalized: AccountStorageV3 | null }>; + saveAccounts: (storage: AccountStorageV3) => Promise; +}): Promise { + return params.restoreAccountsFromBackupPath(params.path, { + persist: params.options?.persist, + backupRoot: params.getNamedBackupRoot(params.getStoragePath()), + realpath: params.realpath, + loadAccountsFromPath: params.loadAccountsFromPath, + saveAccounts: params.saveAccounts, + }); +} diff --git a/test/restore-backup-entry.test.ts b/test/restore-backup-entry.test.ts new file mode 100644 index 00000000..c634b6e9 --- /dev/null +++ b/test/restore-backup-entry.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from "vitest"; +import { restoreAccountsFromBackupEntry } from "../lib/storage/restore-backup-entry.js"; + +describe("restore backup entry", () => { + it("passes path, options, and injected deps through to the restore helper", async () => { + const restoreAccountsFromBackupPath = vi.fn(async () => ({ + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + })); + const loadAccountsFromPath = vi.fn(async () => ({ normalized: null })); + const saveAccounts = vi.fn(async () => undefined); + + const result = await restoreAccountsFromBackupEntry({ + path: "/tmp/backup.json", + options: { persist: false }, + restoreAccountsFromBackupPath, + getNamedBackupRoot: () => "/tmp/backups", + getStoragePath: () => "/tmp/accounts.json", + realpath: vi.fn(async (path) => path), + loadAccountsFromPath, + saveAccounts, + }); + + expect(restoreAccountsFromBackupPath).toHaveBeenCalledWith( + "/tmp/backup.json", + expect.objectContaining({ + persist: false, + backupRoot: "/tmp/backups", + loadAccountsFromPath, + saveAccounts, + }), + ); + expect(result).toEqual({ + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }); + }); +}); From feeb6c54bb2c1b5bb71ee31bf70262df9322a990 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 07:56:53 +0800 Subject: [PATCH 261/376] refactor: extract flagged load facade --- lib/storage.ts | 11 +++++----- lib/storage/flagged-load-entry.ts | 31 ++++++++++++++++++++++++++++ test/flagged-load-entry.test.ts | 34 +++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 5 deletions(-) create mode 100644 lib/storage/flagged-load-entry.ts create mode 100644 test/flagged-load-entry.test.ts diff --git a/lib/storage.ts b/lib/storage.ts index 4ef34620..75439368 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -35,6 +35,7 @@ import { clearFlaggedAccountsEntry, saveFlaggedAccountsEntry, } from "./storage/flagged-entry.js"; +import { loadFlaggedAccountsEntry } from "./storage/flagged-load-entry.js"; import { normalizeFlaggedStorage } from "./storage/flagged-storage.js"; import { clearFlaggedAccountsOnDisk, @@ -1869,17 +1870,17 @@ export async function clearAccounts(): Promise { } export async function loadFlaggedAccounts(): Promise { - const path = getFlaggedAccountsPath(); - return loadFlaggedAccountsState({ - path, - legacyPath: getLegacyFlaggedAccountsPath(), - resetMarkerPath: getIntentionalResetMarkerPath(path), + return loadFlaggedAccountsEntry({ + getFlaggedAccountsPath, + getLegacyFlaggedAccountsPath, + getIntentionalResetMarkerPath, normalizeFlaggedStorage: (data) => normalizeFlaggedStorage(data, { isRecord, now: () => Date.now(), }), saveFlaggedAccounts, + loadFlaggedAccountsState, logError: (message, details) => { log.error(message, details); }, diff --git a/lib/storage/flagged-load-entry.ts b/lib/storage/flagged-load-entry.ts new file mode 100644 index 00000000..8da8eabc --- /dev/null +++ b/lib/storage/flagged-load-entry.ts @@ -0,0 +1,31 @@ +import type { FlaggedAccountStorageV1 } from "../storage.js"; + +export async function loadFlaggedAccountsEntry(params: { + getFlaggedAccountsPath: () => string; + getLegacyFlaggedAccountsPath: () => string; + getIntentionalResetMarkerPath: (path: string) => string; + normalizeFlaggedStorage: (data: unknown) => FlaggedAccountStorageV1; + saveFlaggedAccounts: (storage: FlaggedAccountStorageV1) => Promise; + loadFlaggedAccountsState: (args: { + path: string; + legacyPath: string; + resetMarkerPath: string; + normalizeFlaggedStorage: (data: unknown) => FlaggedAccountStorageV1; + saveFlaggedAccounts: (storage: FlaggedAccountStorageV1) => Promise; + logError: (message: string, details: Record) => void; + logInfo: (message: string, details: Record) => void; + }) => Promise; + logError: (message: string, details: Record) => void; + logInfo: (message: string, details: Record) => void; +}): Promise { + const path = params.getFlaggedAccountsPath(); + return params.loadFlaggedAccountsState({ + path, + legacyPath: params.getLegacyFlaggedAccountsPath(), + resetMarkerPath: params.getIntentionalResetMarkerPath(path), + normalizeFlaggedStorage: params.normalizeFlaggedStorage, + saveFlaggedAccounts: params.saveFlaggedAccounts, + logError: params.logError, + logInfo: params.logInfo, + }); +} diff --git a/test/flagged-load-entry.test.ts b/test/flagged-load-entry.test.ts new file mode 100644 index 00000000..fd5c62b7 --- /dev/null +++ b/test/flagged-load-entry.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from "vitest"; +import { loadFlaggedAccountsEntry } from "../lib/storage/flagged-load-entry.js"; + +describe("flagged load entry", () => { + it("passes paths and deps through to flagged load state helper", async () => { + const loadFlaggedAccountsState = vi.fn(async () => ({ + version: 1, + accounts: [], + })); + const saveFlaggedAccounts = vi.fn(async () => undefined); + + const result = await loadFlaggedAccountsEntry({ + getFlaggedAccountsPath: () => "/tmp/flagged.json", + getLegacyFlaggedAccountsPath: () => "/tmp/legacy-flagged.json", + getIntentionalResetMarkerPath: (path) => `${path}.reset-intent`, + normalizeFlaggedStorage: vi.fn((data) => data as never), + saveFlaggedAccounts, + loadFlaggedAccountsState, + logError: vi.fn(), + logInfo: vi.fn(), + }); + + expect(loadFlaggedAccountsState).toHaveBeenCalledWith({ + path: "/tmp/flagged.json", + legacyPath: "/tmp/legacy-flagged.json", + resetMarkerPath: "/tmp/flagged.json.reset-intent", + normalizeFlaggedStorage: expect.any(Function), + saveFlaggedAccounts, + logError: expect.any(Function), + logInfo: expect.any(Function), + }); + expect(result).toEqual({ version: 1, accounts: [] }); + }); +}); From 6b594b9b1e1cb2938d4b79b1d9bd4cbf42ff0869 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 08:00:00 +0800 Subject: [PATCH 262/376] refactor: extract flagged save facade --- lib/storage.ts | 6 ++---- lib/storage/flagged-save-entry.ts | 11 +++++++++++ test/flagged-save-entry.test.ts | 15 +++++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 lib/storage/flagged-save-entry.ts create mode 100644 test/flagged-save-entry.test.ts diff --git a/lib/storage.ts b/lib/storage.ts index 75439368..41e83034 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -31,11 +31,9 @@ import { } from "./storage/backup-paths.js"; import { restoreAccountsFromBackupPath } from "./storage/backup-restore.js"; import { looksLikeSyntheticFixtureStorage } from "./storage/fixture-guards.js"; -import { - clearFlaggedAccountsEntry, - saveFlaggedAccountsEntry, -} from "./storage/flagged-entry.js"; +import { clearFlaggedAccountsEntry } from "./storage/flagged-entry.js"; import { loadFlaggedAccountsEntry } from "./storage/flagged-load-entry.js"; +import { saveFlaggedAccountsEntry } from "./storage/flagged-save-entry.js"; import { normalizeFlaggedStorage } from "./storage/flagged-storage.js"; import { clearFlaggedAccountsOnDisk, diff --git a/lib/storage/flagged-save-entry.ts b/lib/storage/flagged-save-entry.ts new file mode 100644 index 00000000..0588c7a8 --- /dev/null +++ b/lib/storage/flagged-save-entry.ts @@ -0,0 +1,11 @@ +import type { FlaggedAccountStorageV1 } from "../storage.js"; + +export async function saveFlaggedAccountsEntry(params: { + storage: FlaggedAccountStorageV1; + withStorageLock: (fn: () => Promise) => Promise; + saveUnlocked: (storage: FlaggedAccountStorageV1) => Promise; +}): Promise { + return params.withStorageLock(async () => { + await params.saveUnlocked(params.storage); + }); +} diff --git a/test/flagged-save-entry.test.ts b/test/flagged-save-entry.test.ts new file mode 100644 index 00000000..cd1324e8 --- /dev/null +++ b/test/flagged-save-entry.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it, vi } from "vitest"; +import { saveFlaggedAccountsEntry } from "../lib/storage/flagged-save-entry.js"; + +describe("flagged save entry", () => { + it("delegates save through the storage lock", async () => { + const saveUnlocked = vi.fn(async () => undefined); + await saveFlaggedAccountsEntry({ + storage: { version: 1, accounts: [] }, + withStorageLock: async (fn) => fn(), + saveUnlocked, + }); + + expect(saveUnlocked).toHaveBeenCalledWith({ version: 1, accounts: [] }); + }); +}); From 05defabc87df3cdf16f75f78b41ead65f88ccf4b Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 08:07:23 +0800 Subject: [PATCH 263/376] refactor: extract unified settings entry wrapper --- lib/codex-manager/settings-hub.ts | 4 +- lib/codex-manager/unified-settings-entry.ts | 128 ++++++++++++++++++++ test/unified-settings-entry.test.ts | 37 ++++++ 3 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 lib/codex-manager/unified-settings-entry.ts create mode 100644 test/unified-settings-entry.test.ts diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 4f3517d8..569673cf 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -93,6 +93,7 @@ import { configureUnifiedSettingsController, type SettingsHubActionType, } from "./unified-settings-controller.js"; +import { configureUnifiedSettingsEntry } from "./unified-settings-entry.js"; type DashboardDisplaySettingKey = | "menuShowStatusBadge" @@ -776,7 +777,8 @@ async function promptSettingsHub( async function configureUnifiedSettings( initialSettings?: DashboardDisplaySettings, ): Promise { - return configureUnifiedSettingsController(initialSettings, { + return configureUnifiedSettingsEntry(initialSettings, { + configureUnifiedSettingsController, cloneDashboardSettings, cloneBackendPluginConfig, loadDashboardDisplaySettings, diff --git a/lib/codex-manager/unified-settings-entry.ts b/lib/codex-manager/unified-settings-entry.ts new file mode 100644 index 00000000..b5591d53 --- /dev/null +++ b/lib/codex-manager/unified-settings-entry.ts @@ -0,0 +1,128 @@ +import type { DashboardDisplaySettings } from "../dashboard-settings.js"; +import type { PluginConfig } from "../types.js"; +import type { SettingsHubActionType } from "./unified-settings-controller.js"; + +export async function configureUnifiedSettingsEntry( + initialSettings: DashboardDisplaySettings | undefined, + deps: { + configureUnifiedSettingsController: ( + initialSettings: DashboardDisplaySettings | undefined, + deps: { + cloneDashboardSettings: ( + settings: DashboardDisplaySettings, + ) => DashboardDisplaySettings; + cloneBackendPluginConfig: (config: PluginConfig) => PluginConfig; + loadDashboardDisplaySettings: () => Promise; + loadPluginConfig: () => PluginConfig; + applyUiThemeFromDashboardSettings: ( + settings: DashboardDisplaySettings, + ) => void; + promptSettingsHub: ( + focus: SettingsHubActionType, + ) => Promise<{ type: SettingsHubActionType } | null>; + configureDashboardDisplaySettings: ( + current: DashboardDisplaySettings, + ) => Promise; + configureStatuslineSettings: ( + current: DashboardDisplaySettings, + ) => Promise; + promptBehaviorSettings: ( + current: DashboardDisplaySettings, + ) => Promise; + promptThemeSettings: ( + current: DashboardDisplaySettings, + ) => Promise; + dashboardSettingsEqual: ( + left: DashboardDisplaySettings, + right: DashboardDisplaySettings, + ) => boolean; + persistDashboardSettingsSelection: ( + selected: DashboardDisplaySettings, + keys: readonly (keyof DashboardDisplaySettings)[], + scope: string, + ) => Promise; + promptExperimentalSettings: ( + config: PluginConfig, + ) => Promise; + backendSettingsEqual: ( + left: PluginConfig, + right: PluginConfig, + ) => boolean; + persistBackendConfigSelection: ( + config: PluginConfig, + scope: string, + ) => Promise; + configureBackendSettings: ( + config: PluginConfig, + ) => Promise; + BEHAVIOR_PANEL_KEYS: readonly (keyof DashboardDisplaySettings)[]; + THEME_PANEL_KEYS: readonly (keyof DashboardDisplaySettings)[]; + }, + ) => Promise; + cloneDashboardSettings: ( + settings: DashboardDisplaySettings, + ) => DashboardDisplaySettings; + cloneBackendPluginConfig: (config: PluginConfig) => PluginConfig; + loadDashboardDisplaySettings: () => Promise; + loadPluginConfig: () => PluginConfig; + applyUiThemeFromDashboardSettings: ( + settings: DashboardDisplaySettings, + ) => void; + promptSettingsHub: ( + focus: SettingsHubActionType, + ) => Promise<{ type: SettingsHubActionType } | null>; + configureDashboardDisplaySettings: ( + current: DashboardDisplaySettings, + ) => Promise; + configureStatuslineSettings: ( + current: DashboardDisplaySettings, + ) => Promise; + promptBehaviorSettings: ( + current: DashboardDisplaySettings, + ) => Promise; + promptThemeSettings: ( + current: DashboardDisplaySettings, + ) => Promise; + dashboardSettingsEqual: ( + left: DashboardDisplaySettings, + right: DashboardDisplaySettings, + ) => boolean; + persistDashboardSettingsSelection: ( + selected: DashboardDisplaySettings, + keys: readonly (keyof DashboardDisplaySettings)[], + scope: string, + ) => Promise; + promptExperimentalSettings: ( + config: PluginConfig, + ) => Promise; + backendSettingsEqual: (left: PluginConfig, right: PluginConfig) => boolean; + persistBackendConfigSelection: ( + config: PluginConfig, + scope: string, + ) => Promise; + configureBackendSettings: (config: PluginConfig) => Promise; + BEHAVIOR_PANEL_KEYS: readonly (keyof DashboardDisplaySettings)[]; + THEME_PANEL_KEYS: readonly (keyof DashboardDisplaySettings)[]; + }, +): Promise { + return deps.configureUnifiedSettingsController(initialSettings, { + cloneDashboardSettings: deps.cloneDashboardSettings, + cloneBackendPluginConfig: deps.cloneBackendPluginConfig, + loadDashboardDisplaySettings: deps.loadDashboardDisplaySettings, + loadPluginConfig: deps.loadPluginConfig, + applyUiThemeFromDashboardSettings: deps.applyUiThemeFromDashboardSettings, + promptSettingsHub: deps.promptSettingsHub, + configureDashboardDisplaySettings: deps.configureDashboardDisplaySettings, + configureStatuslineSettings: deps.configureStatuslineSettings, + promptBehaviorSettings: deps.promptBehaviorSettings, + promptThemeSettings: deps.promptThemeSettings, + dashboardSettingsEqual: deps.dashboardSettingsEqual, + persistDashboardSettingsSelection: deps.persistDashboardSettingsSelection, + promptExperimentalSettings: deps.promptExperimentalSettings, + backendSettingsEqual: deps.backendSettingsEqual, + persistBackendConfigSelection: deps.persistBackendConfigSelection, + configureBackendSettings: deps.configureBackendSettings, + BEHAVIOR_PANEL_KEYS: deps.BEHAVIOR_PANEL_KEYS, + THEME_PANEL_KEYS: deps.THEME_PANEL_KEYS, + }); +} diff --git a/test/unified-settings-entry.test.ts b/test/unified-settings-entry.test.ts new file mode 100644 index 00000000..ae473f76 --- /dev/null +++ b/test/unified-settings-entry.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; +import { configureUnifiedSettingsEntry } from "../lib/codex-manager/unified-settings-entry.js"; + +describe("unified settings entry", () => { + it("delegates to the unified settings controller with provided deps", async () => { + const configureUnifiedSettingsController = vi.fn(async () => ({ + menuShowStatusBadge: true, + })); + + const result = await configureUnifiedSettingsEntry(undefined, { + configureUnifiedSettingsController, + cloneDashboardSettings: vi.fn((settings) => settings), + cloneBackendPluginConfig: vi.fn((config) => config), + loadDashboardDisplaySettings: vi.fn(async () => ({ + menuShowStatusBadge: false, + })), + loadPluginConfig: vi.fn(() => ({ fetchTimeoutMs: 1000 })), + applyUiThemeFromDashboardSettings: vi.fn(), + promptSettingsHub: vi.fn(), + configureDashboardDisplaySettings: vi.fn(), + configureStatuslineSettings: vi.fn(), + promptBehaviorSettings: vi.fn(), + promptThemeSettings: vi.fn(), + dashboardSettingsEqual: vi.fn(), + persistDashboardSettingsSelection: vi.fn(), + promptExperimentalSettings: vi.fn(), + backendSettingsEqual: vi.fn(), + persistBackendConfigSelection: vi.fn(), + configureBackendSettings: vi.fn(), + BEHAVIOR_PANEL_KEYS: [], + THEME_PANEL_KEYS: [], + }); + + expect(configureUnifiedSettingsController).toHaveBeenCalled(); + expect(result).toEqual({ menuShowStatusBadge: true }); + }); +}); From 3f63bc1e13fcd966b37d34799f5fbdb4cc0fc4e5 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 08:17:34 +0800 Subject: [PATCH 264/376] refactor: extract settings hub entry wrapper --- lib/codex-manager/settings-hub-entry.ts | 58 +++++++++++++++++++++++++ lib/codex-manager/settings-hub.ts | 5 ++- test/settings-hub-entry.test.ts | 37 ++++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 lib/codex-manager/settings-hub-entry.ts create mode 100644 test/settings-hub-entry.test.ts diff --git a/lib/codex-manager/settings-hub-entry.ts b/lib/codex-manager/settings-hub-entry.ts new file mode 100644 index 00000000..de275fb9 --- /dev/null +++ b/lib/codex-manager/settings-hub-entry.ts @@ -0,0 +1,58 @@ +import type { MenuItem, SelectOptions } from "../ui/select.js"; +import type { SettingsHubActionType } from "./unified-settings-controller.js"; + +export async function promptSettingsHubEntry< + TAction extends { type: SettingsHubActionType }, +>(params: { + initialFocus: TAction["type"]; + promptSettingsHubMenu: ( + initialFocus: TAction["type"], + deps: { + isInteractive: () => boolean; + getUiRuntimeOptions: () => ReturnType< + typeof import("../ui/runtime.js").getUiRuntimeOptions + >; + buildItems: () => MenuItem[]; + findInitialCursor: ( + items: MenuItem[], + initialFocus: TAction["type"], + ) => number | undefined; + select: ( + items: MenuItem[], + options: SelectOptions, + ) => Promise; + copy: { + title: string; + subtitle: string; + help: string; + }; + }, + ) => Promise; + isInteractive: () => boolean; + getUiRuntimeOptions: () => ReturnType< + typeof import("../ui/runtime.js").getUiRuntimeOptions + >; + buildItems: () => MenuItem[]; + findInitialCursor: ( + items: MenuItem[], + initialFocus: TAction["type"], + ) => number | undefined; + select: ( + items: MenuItem[], + options: SelectOptions, + ) => Promise; + copy: { + title: string; + subtitle: string; + help: string; + }; +}): Promise { + return params.promptSettingsHubMenu(params.initialFocus, { + isInteractive: params.isInteractive, + getUiRuntimeOptions: params.getUiRuntimeOptions, + buildItems: params.buildItems, + findInitialCursor: params.findInitialCursor, + select: params.select, + copy: params.copy, + }); +} diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 569673cf..b629aca3 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -70,6 +70,7 @@ import { } from "./experimental-settings-schema.js"; import { loadExperimentalSyncTargetState } from "./experimental-sync-target.js"; import { loadExperimentalSyncTargetEntry } from "./experimental-sync-target-entry.js"; +import { promptSettingsHubEntry } from "./settings-hub-entry.js"; import { buildSettingsHubItems, findSettingsHubInitialCursor, @@ -758,7 +759,9 @@ async function configureBackendSettings( async function promptSettingsHub( initialFocus: SettingsHubAction["type"] = "account-list", ): Promise { - return promptSettingsHubMenu(initialFocus, { + return promptSettingsHubEntry({ + initialFocus, + promptSettingsHubMenu, isInteractive: () => input.isTTY && output.isTTY, getUiRuntimeOptions, buildItems: () => buildSettingsHubItems(UI_COPY.settings), diff --git a/test/settings-hub-entry.test.ts b/test/settings-hub-entry.test.ts new file mode 100644 index 00000000..1d3b1413 --- /dev/null +++ b/test/settings-hub-entry.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; +import { promptSettingsHubEntry } from "../lib/codex-manager/settings-hub-entry.js"; + +describe("settings hub entry", () => { + it("passes focus and dependencies through to the settings hub prompt helper", async () => { + const promptSettingsHubMenu = vi.fn(async () => ({ + type: "back" as const, + })); + const buildItems = vi.fn(() => []); + const findInitialCursor = vi.fn(() => 0); + const select = vi.fn(); + + const result = await promptSettingsHubEntry({ + initialFocus: "account-list", + promptSettingsHubMenu, + isInteractive: () => true, + getUiRuntimeOptions: vi.fn(() => ({ theme: {} }) as never), + buildItems, + findInitialCursor, + select, + copy: { title: "Settings", subtitle: "Subtitle", help: "Help" }, + }); + + expect(promptSettingsHubMenu).toHaveBeenCalledWith( + "account-list", + expect.objectContaining({ + isInteractive: expect.any(Function), + getUiRuntimeOptions: expect.any(Function), + buildItems, + findInitialCursor, + select, + copy: { title: "Settings", subtitle: "Subtitle", help: "Help" }, + }), + ); + expect(result).toEqual({ type: "back" }); + }); +}); From dadda4e572d5fd0f98c1092aedaf67c6c7c4a790 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 08:24:59 +0800 Subject: [PATCH 265/376] refactor: extract dashboard settings entry wrapper --- lib/codex-manager/dashboard-settings-entry.ts | 57 +++++++++++++++++++ lib/codex-manager/settings-hub.ts | 7 ++- test/dashboard-settings-entry.test.ts | 26 +++++++++ 3 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 lib/codex-manager/dashboard-settings-entry.ts create mode 100644 test/dashboard-settings-entry.test.ts diff --git a/lib/codex-manager/dashboard-settings-entry.ts b/lib/codex-manager/dashboard-settings-entry.ts new file mode 100644 index 00000000..b79781d2 --- /dev/null +++ b/lib/codex-manager/dashboard-settings-entry.ts @@ -0,0 +1,57 @@ +import type { DashboardDisplaySettings } from "../dashboard-settings.js"; + +export async function configureDashboardSettingsEntry( + currentSettings: DashboardDisplaySettings | undefined, + deps: { + configureDashboardSettingsController: ( + currentSettings: DashboardDisplaySettings | undefined, + deps: { + loadDashboardDisplaySettings: () => Promise; + promptSettings: ( + settings: DashboardDisplaySettings, + ) => Promise; + settingsEqual: ( + left: DashboardDisplaySettings, + right: DashboardDisplaySettings, + ) => boolean; + persistSelection: ( + selected: DashboardDisplaySettings, + ) => Promise; + applyUiThemeFromDashboardSettings: ( + settings: DashboardDisplaySettings, + ) => void; + isInteractive: () => boolean; + getDashboardSettingsPath: () => string; + writeLine: (message: string) => void; + }, + ) => Promise; + loadDashboardDisplaySettings: () => Promise; + promptSettings: ( + settings: DashboardDisplaySettings, + ) => Promise; + settingsEqual: ( + left: DashboardDisplaySettings, + right: DashboardDisplaySettings, + ) => boolean; + persistSelection: ( + selected: DashboardDisplaySettings, + ) => Promise; + applyUiThemeFromDashboardSettings: ( + settings: DashboardDisplaySettings, + ) => void; + isInteractive: () => boolean; + getDashboardSettingsPath: () => string; + writeLine: (message: string) => void; + }, +): Promise { + return deps.configureDashboardSettingsController(currentSettings, { + loadDashboardDisplaySettings: deps.loadDashboardDisplaySettings, + promptSettings: deps.promptSettings, + settingsEqual: deps.settingsEqual, + persistSelection: deps.persistSelection, + applyUiThemeFromDashboardSettings: deps.applyUiThemeFromDashboardSettings, + isInteractive: deps.isInteractive, + getDashboardSettingsPath: deps.getDashboardSettingsPath, + writeLine: deps.writeLine, + }); +} diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 9fa00128..a93c54ec 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -62,6 +62,7 @@ import { cloneDashboardSettingsData, dashboardSettingsDataEqual, } from "./dashboard-settings-data.js"; +import { configureDashboardSettingsEntry } from "./dashboard-settings-entry.js"; import { promptExperimentalSettingsMenu } from "./experimental-settings-prompt.js"; import { getExperimentalSelectOptions, @@ -477,7 +478,8 @@ async function promptDashboardDisplaySettings( async function configureDashboardDisplaySettings( currentSettings?: DashboardDisplaySettings, ): Promise { - return configureDashboardSettingsController(currentSettings, { + return configureDashboardSettingsEntry(currentSettings, { + configureDashboardSettingsController, loadDashboardDisplaySettings, promptSettings: promptDashboardDisplaySettings, settingsEqual: dashboardSettingsEqual, @@ -533,7 +535,8 @@ async function promptStatuslineSettings( async function configureStatuslineSettings( currentSettings?: DashboardDisplaySettings, ): Promise { - return configureDashboardSettingsController(currentSettings, { + return configureDashboardSettingsEntry(currentSettings, { + configureDashboardSettingsController, loadDashboardDisplaySettings, promptSettings: promptStatuslineSettings, settingsEqual: dashboardSettingsEqual, diff --git a/test/dashboard-settings-entry.test.ts b/test/dashboard-settings-entry.test.ts new file mode 100644 index 00000000..6e3d7b0d --- /dev/null +++ b/test/dashboard-settings-entry.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from "vitest"; +import { configureDashboardSettingsEntry } from "../lib/codex-manager/dashboard-settings-entry.js"; + +describe("dashboard settings entry", () => { + it("delegates to dashboard settings controller with provided deps", async () => { + const configureDashboardSettingsController = vi.fn(async () => ({ + menuShowStatusBadge: false, + })); + const result = await configureDashboardSettingsEntry(undefined, { + configureDashboardSettingsController, + loadDashboardDisplaySettings: vi.fn(async () => ({ + menuShowStatusBadge: true, + })), + promptSettings: vi.fn(), + settingsEqual: vi.fn(() => false), + persistSelection: vi.fn(), + applyUiThemeFromDashboardSettings: vi.fn(), + isInteractive: vi.fn(() => true), + getDashboardSettingsPath: vi.fn(() => "/tmp/settings.json"), + writeLine: vi.fn(), + }); + + expect(configureDashboardSettingsController).toHaveBeenCalled(); + expect(result).toEqual({ menuShowStatusBadge: false }); + }); +}); From f8a68bf6422f1ec69c55a40bcd115ce113ab86ea Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 08:37:52 +0800 Subject: [PATCH 266/376] refactor: extract ui runtime bridge wrapper --- index.ts | 10 +++++++--- lib/runtime/ui-runtime-entry.ts | 23 +++++++++++++++++++++++ test/ui-runtime-entry.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 lib/runtime/ui-runtime-entry.ts create mode 100644 test/ui-runtime-entry.test.ts diff --git a/index.ts b/index.ts index 60b44133..45882d05 100644 --- a/index.ts +++ b/index.ts @@ -229,6 +229,7 @@ import { applyUiRuntimeFromConfig, getStatusMarker, } from "./lib/runtime/ui-runtime.js"; +import { resolveUiRuntimeEntry } from "./lib/runtime/ui-runtime-entry.js"; import { SessionAffinityStore } from "./lib/session-affinity.js"; import { registerCleanup } from "./lib/shutdown.js"; import { @@ -514,9 +515,12 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }; const resolveUiRuntime = (): UiRuntimeOptions => { - return resolveUiRuntimeFromConfig(loadPluginConfig, (pluginConfig) => - applyUiRuntimeFromConfig(pluginConfig, setUiRuntimeOptions), - ); + return resolveUiRuntimeEntry({ + loadPluginConfig, + resolveUiRuntimeFromConfig, + applyUiRuntimeFromConfig: (pluginConfig) => + applyUiRuntimeFromConfig(pluginConfig, setUiRuntimeOptions), + }); }; const invalidateAccountManagerCache = (): void => { diff --git a/lib/runtime/ui-runtime-entry.ts b/lib/runtime/ui-runtime-entry.ts new file mode 100644 index 00000000..86af9640 --- /dev/null +++ b/lib/runtime/ui-runtime-entry.ts @@ -0,0 +1,23 @@ +import type { UiRuntimeOptions } from "../ui/runtime.js"; + +export function resolveUiRuntimeEntry(params: { + loadPluginConfig: () => ReturnType< + typeof import("../config.js").loadPluginConfig + >; + resolveUiRuntimeFromConfig: ( + loadPluginConfig: () => ReturnType< + typeof import("../config.js").loadPluginConfig + >, + applyUiRuntimeFromConfig: ( + pluginConfig: ReturnType, + ) => UiRuntimeOptions, + ) => UiRuntimeOptions; + applyUiRuntimeFromConfig: ( + pluginConfig: ReturnType, + ) => UiRuntimeOptions; +}): UiRuntimeOptions { + return params.resolveUiRuntimeFromConfig( + params.loadPluginConfig, + params.applyUiRuntimeFromConfig, + ); +} diff --git a/test/ui-runtime-entry.test.ts b/test/ui-runtime-entry.test.ts new file mode 100644 index 00000000..17d9d48b --- /dev/null +++ b/test/ui-runtime-entry.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from "vitest"; +import { resolveUiRuntimeEntry } from "../lib/runtime/ui-runtime-entry.js"; + +describe("ui runtime entry", () => { + it("passes loader and apply callback through to the ui runtime resolver", () => { + const loadPluginConfig = vi.fn(() => ({ a: 1 })); + const resolveUiRuntimeFromConfig = vi.fn(() => ({ theme: {} })); + const applyUiRuntimeFromConfig = vi.fn(() => ({ theme: {} })); + + const result = resolveUiRuntimeEntry({ + loadPluginConfig: loadPluginConfig as never, + resolveUiRuntimeFromConfig: resolveUiRuntimeFromConfig as never, + applyUiRuntimeFromConfig: applyUiRuntimeFromConfig as never, + }); + + expect(resolveUiRuntimeFromConfig).toHaveBeenCalledWith( + loadPluginConfig, + applyUiRuntimeFromConfig, + ); + expect(result).toEqual({ theme: {} }); + }); +}); From 8f644ea8bf0d2d040103d2cddaacd8e7ffd32ceb Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 09:01:12 +0800 Subject: [PATCH 267/376] refactor: extract settings panel entry wrappers --- lib/codex-manager/settings-hub.ts | 51 ++++----- lib/codex-manager/settings-panels.ts | 148 +++++++++++++++++++++++++++ test/settings-panels.test.ts | 21 ++++ 3 files changed, 189 insertions(+), 31 deletions(-) create mode 100644 lib/codex-manager/settings-panels.ts create mode 100644 test/settings-panels.test.ts diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index a93c54ec..58902850 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -75,6 +75,13 @@ import { findSettingsHubInitialCursor, } from "./settings-hub-menu.js"; import { promptSettingsHubMenu } from "./settings-hub-prompt.js"; +import { + promptBehaviorSettingsPanelEntry, + promptDashboardDisplaySettingsPanelEntry, + promptStatuslineSettingsPanelEntry, + promptThemeSettingsPanelEntry, + reorderStatuslineField, +} from "./settings-panels.js"; import { readFileWithRetry, resolvePluginConfigSavePathKey, @@ -448,7 +455,7 @@ const __testOnly = { buildAccountListPreview, buildSummaryPreviewText, normalizeStatuslineFields, - reorderField, + reorderField: reorderStatuslineField, promptDashboardDisplaySettings, promptStatuslineSettings, promptBehaviorSettings, @@ -460,7 +467,9 @@ const __testOnly = { async function promptDashboardDisplaySettings( initial: DashboardDisplaySettings, ): Promise { - return promptDashboardDisplayPanel(initial, { + return promptDashboardDisplaySettingsPanelEntry({ + initial, + promptDashboardDisplayPanel, cloneDashboardSettings, buildAccountListPreview, formatDashboardSettingState, @@ -498,33 +507,16 @@ async function configureDashboardDisplaySettings( }); } -function reorderField( - fields: DashboardStatuslineField[], - key: DashboardStatuslineField, - direction: -1 | 1, -): DashboardStatuslineField[] { - const index = fields.indexOf(key); - if (index < 0) return fields; - const target = index + direction; - if (target < 0 || target >= fields.length) return fields; - const next = [...fields]; - const current = next[index]; - const swap = next[target]; - if (!current || !swap) return fields; - next[index] = swap; - next[target] = current; - return next; -} - async function promptStatuslineSettings( initial: DashboardDisplaySettings, ): Promise { - return promptStatuslineSettingsPanel(initial, { + return promptStatuslineSettingsPanelEntry({ + initial, + promptStatuslineSettingsPanel, cloneDashboardSettings, buildAccountListPreview, normalizeStatuslineFields, formatDashboardSettingState, - reorderField, applyDashboardDefaultsForKeys, STATUSLINE_FIELD_OPTIONS, STATUSLINE_PANEL_KEYS, @@ -555,19 +547,14 @@ async function configureStatuslineSettings( }); } -function formatDelayLabel(delayMs: number): string { - return delayMs <= 0 - ? "Instant return" - : `${Math.round(delayMs / 1000)}s auto-return`; -} - async function promptBehaviorSettings( initial: DashboardDisplaySettings, ): Promise { - return promptBehaviorSettingsPanel(initial, { + return promptBehaviorSettingsPanelEntry({ + initial, + promptBehaviorSettingsPanel, cloneDashboardSettings, applyDashboardDefaultsForKeys, - formatDelayLabel, formatMenuQuotaTtl, AUTO_RETURN_OPTIONS_MS, MENU_QUOTA_TTL_OPTIONS_MS, @@ -579,7 +566,9 @@ async function promptBehaviorSettings( async function promptThemeSettings( initial: DashboardDisplaySettings, ): Promise { - return promptThemeSettingsPanel(initial, { + return promptThemeSettingsPanelEntry({ + initial, + promptThemeSettingsPanel, cloneDashboardSettings, applyDashboardDefaultsForKeys, applyUiThemeFromDashboardSettings, diff --git a/lib/codex-manager/settings-panels.ts b/lib/codex-manager/settings-panels.ts new file mode 100644 index 00000000..a7d87e12 --- /dev/null +++ b/lib/codex-manager/settings-panels.ts @@ -0,0 +1,148 @@ +import { + type DashboardDisplaySettings, + type DashboardStatuslineField, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS, +} from "../dashboard-settings.js"; +import type { BehaviorSettingsPanelDeps } from "./behavior-settings-panel.js"; +import type { DashboardDisplayPanelDeps } from "./dashboard-display-panel.js"; +import type { StatuslineSettingsPanelDeps } from "./statusline-settings-panel.js"; +import type { ThemeSettingsPanelDeps } from "./theme-settings-panel.js"; + +export async function promptDashboardDisplaySettingsPanelEntry(params: { + initial: DashboardDisplaySettings; + promptDashboardDisplayPanel: ( + initial: DashboardDisplaySettings, + deps: DashboardDisplayPanelDeps, + ) => Promise; + cloneDashboardSettings: DashboardDisplayPanelDeps["cloneDashboardSettings"]; + buildAccountListPreview: DashboardDisplayPanelDeps["buildAccountListPreview"]; + formatDashboardSettingState: DashboardDisplayPanelDeps["formatDashboardSettingState"]; + formatMenuSortMode: DashboardDisplayPanelDeps["formatMenuSortMode"]; + resolveMenuLayoutMode: ( + settings?: DashboardDisplaySettings, + ) => "compact-details" | "expanded-rows"; + formatMenuLayoutMode: DashboardDisplayPanelDeps["formatMenuLayoutMode"]; + applyDashboardDefaultsForKeys: DashboardDisplayPanelDeps["applyDashboardDefaultsForKeys"]; + DASHBOARD_DISPLAY_OPTIONS: DashboardDisplayPanelDeps["DASHBOARD_DISPLAY_OPTIONS"]; + ACCOUNT_LIST_PANEL_KEYS: DashboardDisplayPanelDeps["ACCOUNT_LIST_PANEL_KEYS"]; + UI_COPY: DashboardDisplayPanelDeps["UI_COPY"]; +}): Promise { + return params.promptDashboardDisplayPanel(params.initial, { + cloneDashboardSettings: params.cloneDashboardSettings, + buildAccountListPreview: params.buildAccountListPreview, + formatDashboardSettingState: params.formatDashboardSettingState, + formatMenuSortMode: params.formatMenuSortMode, + resolveMenuLayoutMode: (settings) => + params.resolveMenuLayoutMode( + settings ?? DEFAULT_DASHBOARD_DISPLAY_SETTINGS, + ) ?? "compact-details", + formatMenuLayoutMode: params.formatMenuLayoutMode, + applyDashboardDefaultsForKeys: params.applyDashboardDefaultsForKeys, + DASHBOARD_DISPLAY_OPTIONS: params.DASHBOARD_DISPLAY_OPTIONS, + ACCOUNT_LIST_PANEL_KEYS: params.ACCOUNT_LIST_PANEL_KEYS, + UI_COPY: params.UI_COPY, + }); +} + +export function reorderStatuslineField( + fields: DashboardStatuslineField[], + key: DashboardStatuslineField, + direction: -1 | 1, +): DashboardStatuslineField[] { + const index = fields.indexOf(key); + if (index < 0) return fields; + const target = index + direction; + if (target < 0 || target >= fields.length) return fields; + const next = [...fields]; + const current = next[index]; + const swap = next[target]; + if (!current || !swap) return fields; + next[index] = swap; + next[target] = current; + return next; +} + +export async function promptStatuslineSettingsPanelEntry(params: { + initial: DashboardDisplaySettings; + promptStatuslineSettingsPanel: ( + initial: DashboardDisplaySettings, + deps: StatuslineSettingsPanelDeps, + ) => Promise; + cloneDashboardSettings: StatuslineSettingsPanelDeps["cloneDashboardSettings"]; + buildAccountListPreview: StatuslineSettingsPanelDeps["buildAccountListPreview"]; + normalizeStatuslineFields: StatuslineSettingsPanelDeps["normalizeStatuslineFields"]; + formatDashboardSettingState: StatuslineSettingsPanelDeps["formatDashboardSettingState"]; + applyDashboardDefaultsForKeys: StatuslineSettingsPanelDeps["applyDashboardDefaultsForKeys"]; + STATUSLINE_FIELD_OPTIONS: StatuslineSettingsPanelDeps["STATUSLINE_FIELD_OPTIONS"]; + STATUSLINE_PANEL_KEYS: StatuslineSettingsPanelDeps["STATUSLINE_PANEL_KEYS"]; + UI_COPY: StatuslineSettingsPanelDeps["UI_COPY"]; +}): Promise { + return params.promptStatuslineSettingsPanel(params.initial, { + cloneDashboardSettings: params.cloneDashboardSettings, + buildAccountListPreview: params.buildAccountListPreview, + normalizeStatuslineFields: params.normalizeStatuslineFields, + formatDashboardSettingState: params.formatDashboardSettingState, + reorderField: reorderStatuslineField, + applyDashboardDefaultsForKeys: params.applyDashboardDefaultsForKeys, + STATUSLINE_FIELD_OPTIONS: params.STATUSLINE_FIELD_OPTIONS, + STATUSLINE_PANEL_KEYS: params.STATUSLINE_PANEL_KEYS, + UI_COPY: params.UI_COPY, + }); +} + +export function formatAutoReturnDelayLabel(delayMs: number): string { + return delayMs <= 0 + ? "Instant return" + : `${Math.round(delayMs / 1000)}s auto-return`; +} + +export async function promptBehaviorSettingsPanelEntry(params: { + initial: DashboardDisplaySettings; + promptBehaviorSettingsPanel: ( + initial: DashboardDisplaySettings, + deps: BehaviorSettingsPanelDeps, + ) => Promise; + cloneDashboardSettings: BehaviorSettingsPanelDeps["cloneDashboardSettings"]; + applyDashboardDefaultsForKeys: BehaviorSettingsPanelDeps["applyDashboardDefaultsForKeys"]; + formatMenuQuotaTtl: BehaviorSettingsPanelDeps["formatMenuQuotaTtl"]; + AUTO_RETURN_OPTIONS_MS: BehaviorSettingsPanelDeps["AUTO_RETURN_OPTIONS_MS"]; + MENU_QUOTA_TTL_OPTIONS_MS: BehaviorSettingsPanelDeps["MENU_QUOTA_TTL_OPTIONS_MS"]; + BEHAVIOR_PANEL_KEYS: BehaviorSettingsPanelDeps["BEHAVIOR_PANEL_KEYS"]; + UI_COPY: BehaviorSettingsPanelDeps["UI_COPY"]; +}): Promise { + return params.promptBehaviorSettingsPanel(params.initial, { + cloneDashboardSettings: params.cloneDashboardSettings, + applyDashboardDefaultsForKeys: params.applyDashboardDefaultsForKeys, + formatDelayLabel: formatAutoReturnDelayLabel, + formatMenuQuotaTtl: params.formatMenuQuotaTtl, + AUTO_RETURN_OPTIONS_MS: params.AUTO_RETURN_OPTIONS_MS, + MENU_QUOTA_TTL_OPTIONS_MS: params.MENU_QUOTA_TTL_OPTIONS_MS, + BEHAVIOR_PANEL_KEYS: params.BEHAVIOR_PANEL_KEYS, + UI_COPY: params.UI_COPY, + }); +} + +export async function promptThemeSettingsPanelEntry(params: { + initial: DashboardDisplaySettings; + promptThemeSettingsPanel: ( + initial: DashboardDisplaySettings, + deps: ThemeSettingsPanelDeps, + ) => Promise; + cloneDashboardSettings: ThemeSettingsPanelDeps["cloneDashboardSettings"]; + applyDashboardDefaultsForKeys: ThemeSettingsPanelDeps["applyDashboardDefaultsForKeys"]; + applyUiThemeFromDashboardSettings: ThemeSettingsPanelDeps["applyUiThemeFromDashboardSettings"]; + THEME_PRESET_OPTIONS: ThemeSettingsPanelDeps["THEME_PRESET_OPTIONS"]; + ACCENT_COLOR_OPTIONS: ThemeSettingsPanelDeps["ACCENT_COLOR_OPTIONS"]; + THEME_PANEL_KEYS: ThemeSettingsPanelDeps["THEME_PANEL_KEYS"]; + UI_COPY: ThemeSettingsPanelDeps["UI_COPY"]; +}): Promise { + return params.promptThemeSettingsPanel(params.initial, { + cloneDashboardSettings: params.cloneDashboardSettings, + applyDashboardDefaultsForKeys: params.applyDashboardDefaultsForKeys, + applyUiThemeFromDashboardSettings: params.applyUiThemeFromDashboardSettings, + THEME_PRESET_OPTIONS: params.THEME_PRESET_OPTIONS, + ACCENT_COLOR_OPTIONS: params.ACCENT_COLOR_OPTIONS, + THEME_PANEL_KEYS: params.THEME_PANEL_KEYS, + UI_COPY: params.UI_COPY, + }); +} diff --git a/test/settings-panels.test.ts b/test/settings-panels.test.ts new file mode 100644 index 00000000..e6b77e49 --- /dev/null +++ b/test/settings-panels.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { + formatAutoReturnDelayLabel, + reorderStatuslineField, +} from "../lib/codex-manager/settings-panels.js"; + +describe("settings panel helpers", () => { + it("reorders statusline fields safely", () => { + expect( + reorderStatuslineField(["last-used", "limits", "status"], "limits", -1), + ).toEqual(["limits", "last-used", "status"]); + expect(reorderStatuslineField(["last-used"], "last-used", -1)).toEqual([ + "last-used", + ]); + }); + + it("formats auto return delay labels", () => { + expect(formatAutoReturnDelayLabel(0)).toBe("Instant return"); + expect(formatAutoReturnDelayLabel(4000)).toBe("4s auto-return"); + }); +}); From 9c401b506e033b31072d813501e0c27061c2384d Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 09:07:56 +0800 Subject: [PATCH 268/376] refactor: extract account manager cache entry wrapper --- index.ts | 28 ++++++++++----- lib/runtime/account-manager-cache-entry.ts | 40 +++++++++++++++++++++ test/account-manager-cache-entry.test.ts | 42 ++++++++++++++++++++++ 3 files changed, 102 insertions(+), 8 deletions(-) create mode 100644 lib/runtime/account-manager-cache-entry.ts create mode 100644 test/account-manager-cache-entry.test.ts diff --git a/index.ts b/index.ts index 45882d05..6e2f8ef6 100644 --- a/index.ts +++ b/index.ts @@ -197,6 +197,10 @@ import { invalidateAccountManagerCacheState, reloadAccountManagerFromDiskState, } from "./lib/runtime/account-manager-cache.js"; +import { + invalidateAccountManagerCacheEntry, + reloadAccountManagerFromDiskEntry, +} from "./lib/runtime/account-manager-cache-entry.js"; import { type TokenSuccessWithAccount as AccountPoolTokenSuccessWithAccount, persistAccountPoolResults, @@ -524,18 +528,25 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }; const invalidateAccountManagerCache = (): void => { - const next = invalidateAccountManagerCacheState(); - cachedAccountManager = next.cachedAccountManager; - accountManagerPromise = next.accountManagerPromise; + invalidateAccountManagerCacheEntry({ + invalidateAccountManagerCacheState, + setCachedAccountManager: (manager) => { + cachedAccountManager = manager; + }, + setAccountManagerPromise: (promise) => { + accountManagerPromise = promise; + }, + }); }; const reloadAccountManagerFromDisk = async ( authFallback?: OAuthAuthDetails, - ): Promise => { - accountReloadInFlight = reloadAccountManagerFromDiskState({ + ): Promise => + reloadAccountManagerFromDiskEntry({ + authFallback, currentReloadInFlight: accountReloadInFlight, + reloadAccountManagerFromDiskState, loadFromDisk: (fallback) => AccountManager.loadFromDisk(fallback), - authFallback, onLoaded: (reloaded) => { cachedAccountManager = reloaded; accountManagerPromise = Promise.resolve(reloaded); @@ -543,9 +554,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { onSettled: () => { accountReloadInFlight = null; }, + setReloadInFlight: (promise) => { + accountReloadInFlight = promise; + }, }); - return accountReloadInFlight; - }; const applyAccountStorageScope = ( pluginConfig: ReturnType, diff --git a/lib/runtime/account-manager-cache-entry.ts b/lib/runtime/account-manager-cache-entry.ts new file mode 100644 index 00000000..ebb4ffa4 --- /dev/null +++ b/lib/runtime/account-manager-cache-entry.ts @@ -0,0 +1,40 @@ +import type { OAuthAuthDetails } from "../types.js"; + +export function invalidateAccountManagerCacheEntry(params: { + invalidateAccountManagerCacheState: () => { + cachedAccountManager: null; + accountManagerPromise: null; + }; + setCachedAccountManager: (manager: TManager | null) => void; + setAccountManagerPromise: (promise: Promise | null) => void; +}): void { + const next = params.invalidateAccountManagerCacheState(); + params.setCachedAccountManager(next.cachedAccountManager); + params.setAccountManagerPromise(next.accountManagerPromise); +} + +export async function reloadAccountManagerFromDiskEntry(params: { + authFallback?: OAuthAuthDetails; + currentReloadInFlight: Promise | null; + reloadAccountManagerFromDiskState: (args: { + currentReloadInFlight: Promise | null; + loadFromDisk: (authFallback?: OAuthAuthDetails) => Promise; + authFallback?: OAuthAuthDetails; + onLoaded: (manager: TManager) => void; + onSettled: () => void; + }) => Promise; + loadFromDisk: (authFallback?: OAuthAuthDetails) => Promise; + onLoaded: (manager: TManager) => void; + onSettled: () => void; + setReloadInFlight: (promise: Promise) => void; +}): Promise { + const inFlight = params.reloadAccountManagerFromDiskState({ + currentReloadInFlight: params.currentReloadInFlight, + loadFromDisk: params.loadFromDisk, + authFallback: params.authFallback, + onLoaded: params.onLoaded, + onSettled: params.onSettled, + }); + params.setReloadInFlight(inFlight); + return inFlight; +} diff --git a/test/account-manager-cache-entry.test.ts b/test/account-manager-cache-entry.test.ts new file mode 100644 index 00000000..0cdf821c --- /dev/null +++ b/test/account-manager-cache-entry.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from "vitest"; +import { + invalidateAccountManagerCacheEntry, + reloadAccountManagerFromDiskEntry, +} from "../lib/runtime/account-manager-cache-entry.js"; + +describe("account manager cache entry", () => { + it("delegates cache invalidation state into the setter callbacks", () => { + const setCachedAccountManager = vi.fn(); + const setAccountManagerPromise = vi.fn(); + + invalidateAccountManagerCacheEntry({ + invalidateAccountManagerCacheState: () => ({ + cachedAccountManager: null, + accountManagerPromise: null, + }), + setCachedAccountManager, + setAccountManagerPromise, + }); + + expect(setCachedAccountManager).toHaveBeenCalledWith(null); + expect(setAccountManagerPromise).toHaveBeenCalledWith(null); + }); + + it("delegates reload state into the injected runtime callbacks", async () => { + const reloadState = vi.fn(async () => ({ id: 1 })); + const setReloadInFlight = vi.fn(); + + const result = await reloadAccountManagerFromDiskEntry({ + currentReloadInFlight: null, + reloadAccountManagerFromDiskState: reloadState, + loadFromDisk: vi.fn(async () => ({ id: 1 })), + onLoaded: vi.fn(), + onSettled: vi.fn(), + setReloadInFlight, + }); + + expect(reloadState).toHaveBeenCalled(); + expect(setReloadInFlight).toHaveBeenCalledWith(expect.any(Promise)); + expect(result).toEqual({ id: 1 }); + }); +}); From 24d3f0bed049bedccb57d369bcb54167c0ba0e8b Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 09:13:40 +0800 Subject: [PATCH 269/376] refactor: extract settings hub entry wrapper --- lib/codex-manager/settings-hub-entry.ts | 58 +++++++++++++++++++++++++ lib/codex-manager/settings-hub.ts | 5 ++- test/settings-hub-entry.test.ts | 37 ++++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 lib/codex-manager/settings-hub-entry.ts create mode 100644 test/settings-hub-entry.test.ts diff --git a/lib/codex-manager/settings-hub-entry.ts b/lib/codex-manager/settings-hub-entry.ts new file mode 100644 index 00000000..de275fb9 --- /dev/null +++ b/lib/codex-manager/settings-hub-entry.ts @@ -0,0 +1,58 @@ +import type { MenuItem, SelectOptions } from "../ui/select.js"; +import type { SettingsHubActionType } from "./unified-settings-controller.js"; + +export async function promptSettingsHubEntry< + TAction extends { type: SettingsHubActionType }, +>(params: { + initialFocus: TAction["type"]; + promptSettingsHubMenu: ( + initialFocus: TAction["type"], + deps: { + isInteractive: () => boolean; + getUiRuntimeOptions: () => ReturnType< + typeof import("../ui/runtime.js").getUiRuntimeOptions + >; + buildItems: () => MenuItem[]; + findInitialCursor: ( + items: MenuItem[], + initialFocus: TAction["type"], + ) => number | undefined; + select: ( + items: MenuItem[], + options: SelectOptions, + ) => Promise; + copy: { + title: string; + subtitle: string; + help: string; + }; + }, + ) => Promise; + isInteractive: () => boolean; + getUiRuntimeOptions: () => ReturnType< + typeof import("../ui/runtime.js").getUiRuntimeOptions + >; + buildItems: () => MenuItem[]; + findInitialCursor: ( + items: MenuItem[], + initialFocus: TAction["type"], + ) => number | undefined; + select: ( + items: MenuItem[], + options: SelectOptions, + ) => Promise; + copy: { + title: string; + subtitle: string; + help: string; + }; +}): Promise { + return params.promptSettingsHubMenu(params.initialFocus, { + isInteractive: params.isInteractive, + getUiRuntimeOptions: params.getUiRuntimeOptions, + buildItems: params.buildItems, + findInitialCursor: params.findInitialCursor, + select: params.select, + copy: params.copy, + }); +} diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 58902850..fd794c3c 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -70,6 +70,7 @@ import { mapExperimentalStatusHotkey, } from "./experimental-settings-schema.js"; import { loadExperimentalSyncTargetState } from "./experimental-sync-target.js"; +import { promptSettingsHubEntry } from "./settings-hub-entry.js"; import { buildSettingsHubItems, findSettingsHubInitialCursor, @@ -759,7 +760,9 @@ async function configureBackendSettings( async function promptSettingsHub( initialFocus: SettingsHubAction["type"] = "account-list", ): Promise { - return promptSettingsHubMenu(initialFocus, { + return promptSettingsHubEntry({ + initialFocus, + promptSettingsHubMenu, isInteractive: () => input.isTTY && output.isTTY, getUiRuntimeOptions, buildItems: () => buildSettingsHubItems(UI_COPY.settings), diff --git a/test/settings-hub-entry.test.ts b/test/settings-hub-entry.test.ts new file mode 100644 index 00000000..1d3b1413 --- /dev/null +++ b/test/settings-hub-entry.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; +import { promptSettingsHubEntry } from "../lib/codex-manager/settings-hub-entry.js"; + +describe("settings hub entry", () => { + it("passes focus and dependencies through to the settings hub prompt helper", async () => { + const promptSettingsHubMenu = vi.fn(async () => ({ + type: "back" as const, + })); + const buildItems = vi.fn(() => []); + const findInitialCursor = vi.fn(() => 0); + const select = vi.fn(); + + const result = await promptSettingsHubEntry({ + initialFocus: "account-list", + promptSettingsHubMenu, + isInteractive: () => true, + getUiRuntimeOptions: vi.fn(() => ({ theme: {} }) as never), + buildItems, + findInitialCursor, + select, + copy: { title: "Settings", subtitle: "Subtitle", help: "Help" }, + }); + + expect(promptSettingsHubMenu).toHaveBeenCalledWith( + "account-list", + expect.objectContaining({ + isInteractive: expect.any(Function), + getUiRuntimeOptions: expect.any(Function), + buildItems, + findInitialCursor, + select, + copy: { title: "Settings", subtitle: "Subtitle", help: "Help" }, + }), + ); + expect(result).toEqual({ type: "back" }); + }); +}); From 5ec0ad1a590513df6a73a91c14c95ce037e47cfa Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 09:19:00 +0800 Subject: [PATCH 270/376] refactor: extract unified settings entry wrapper --- lib/codex-manager/settings-hub.ts | 4 +- lib/codex-manager/unified-settings-entry.ts | 128 ++++++++++++++++++++ test/unified-settings-entry.test.ts | 37 ++++++ 3 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 lib/codex-manager/unified-settings-entry.ts create mode 100644 test/unified-settings-entry.test.ts diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index fd794c3c..4e1cc2cc 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -101,6 +101,7 @@ import { configureUnifiedSettingsController, type SettingsHubActionType, } from "./unified-settings-controller.js"; +import { configureUnifiedSettingsEntry } from "./unified-settings-entry.js"; type DashboardDisplaySettingKey = | "menuShowStatusBadge" @@ -781,7 +782,8 @@ async function promptSettingsHub( async function configureUnifiedSettings( initialSettings?: DashboardDisplaySettings, ): Promise { - return configureUnifiedSettingsController(initialSettings, { + return configureUnifiedSettingsEntry(initialSettings, { + configureUnifiedSettingsController, cloneDashboardSettings, cloneBackendPluginConfig, loadDashboardDisplaySettings, diff --git a/lib/codex-manager/unified-settings-entry.ts b/lib/codex-manager/unified-settings-entry.ts new file mode 100644 index 00000000..b5591d53 --- /dev/null +++ b/lib/codex-manager/unified-settings-entry.ts @@ -0,0 +1,128 @@ +import type { DashboardDisplaySettings } from "../dashboard-settings.js"; +import type { PluginConfig } from "../types.js"; +import type { SettingsHubActionType } from "./unified-settings-controller.js"; + +export async function configureUnifiedSettingsEntry( + initialSettings: DashboardDisplaySettings | undefined, + deps: { + configureUnifiedSettingsController: ( + initialSettings: DashboardDisplaySettings | undefined, + deps: { + cloneDashboardSettings: ( + settings: DashboardDisplaySettings, + ) => DashboardDisplaySettings; + cloneBackendPluginConfig: (config: PluginConfig) => PluginConfig; + loadDashboardDisplaySettings: () => Promise; + loadPluginConfig: () => PluginConfig; + applyUiThemeFromDashboardSettings: ( + settings: DashboardDisplaySettings, + ) => void; + promptSettingsHub: ( + focus: SettingsHubActionType, + ) => Promise<{ type: SettingsHubActionType } | null>; + configureDashboardDisplaySettings: ( + current: DashboardDisplaySettings, + ) => Promise; + configureStatuslineSettings: ( + current: DashboardDisplaySettings, + ) => Promise; + promptBehaviorSettings: ( + current: DashboardDisplaySettings, + ) => Promise; + promptThemeSettings: ( + current: DashboardDisplaySettings, + ) => Promise; + dashboardSettingsEqual: ( + left: DashboardDisplaySettings, + right: DashboardDisplaySettings, + ) => boolean; + persistDashboardSettingsSelection: ( + selected: DashboardDisplaySettings, + keys: readonly (keyof DashboardDisplaySettings)[], + scope: string, + ) => Promise; + promptExperimentalSettings: ( + config: PluginConfig, + ) => Promise; + backendSettingsEqual: ( + left: PluginConfig, + right: PluginConfig, + ) => boolean; + persistBackendConfigSelection: ( + config: PluginConfig, + scope: string, + ) => Promise; + configureBackendSettings: ( + config: PluginConfig, + ) => Promise; + BEHAVIOR_PANEL_KEYS: readonly (keyof DashboardDisplaySettings)[]; + THEME_PANEL_KEYS: readonly (keyof DashboardDisplaySettings)[]; + }, + ) => Promise; + cloneDashboardSettings: ( + settings: DashboardDisplaySettings, + ) => DashboardDisplaySettings; + cloneBackendPluginConfig: (config: PluginConfig) => PluginConfig; + loadDashboardDisplaySettings: () => Promise; + loadPluginConfig: () => PluginConfig; + applyUiThemeFromDashboardSettings: ( + settings: DashboardDisplaySettings, + ) => void; + promptSettingsHub: ( + focus: SettingsHubActionType, + ) => Promise<{ type: SettingsHubActionType } | null>; + configureDashboardDisplaySettings: ( + current: DashboardDisplaySettings, + ) => Promise; + configureStatuslineSettings: ( + current: DashboardDisplaySettings, + ) => Promise; + promptBehaviorSettings: ( + current: DashboardDisplaySettings, + ) => Promise; + promptThemeSettings: ( + current: DashboardDisplaySettings, + ) => Promise; + dashboardSettingsEqual: ( + left: DashboardDisplaySettings, + right: DashboardDisplaySettings, + ) => boolean; + persistDashboardSettingsSelection: ( + selected: DashboardDisplaySettings, + keys: readonly (keyof DashboardDisplaySettings)[], + scope: string, + ) => Promise; + promptExperimentalSettings: ( + config: PluginConfig, + ) => Promise; + backendSettingsEqual: (left: PluginConfig, right: PluginConfig) => boolean; + persistBackendConfigSelection: ( + config: PluginConfig, + scope: string, + ) => Promise; + configureBackendSettings: (config: PluginConfig) => Promise; + BEHAVIOR_PANEL_KEYS: readonly (keyof DashboardDisplaySettings)[]; + THEME_PANEL_KEYS: readonly (keyof DashboardDisplaySettings)[]; + }, +): Promise { + return deps.configureUnifiedSettingsController(initialSettings, { + cloneDashboardSettings: deps.cloneDashboardSettings, + cloneBackendPluginConfig: deps.cloneBackendPluginConfig, + loadDashboardDisplaySettings: deps.loadDashboardDisplaySettings, + loadPluginConfig: deps.loadPluginConfig, + applyUiThemeFromDashboardSettings: deps.applyUiThemeFromDashboardSettings, + promptSettingsHub: deps.promptSettingsHub, + configureDashboardDisplaySettings: deps.configureDashboardDisplaySettings, + configureStatuslineSettings: deps.configureStatuslineSettings, + promptBehaviorSettings: deps.promptBehaviorSettings, + promptThemeSettings: deps.promptThemeSettings, + dashboardSettingsEqual: deps.dashboardSettingsEqual, + persistDashboardSettingsSelection: deps.persistDashboardSettingsSelection, + promptExperimentalSettings: deps.promptExperimentalSettings, + backendSettingsEqual: deps.backendSettingsEqual, + persistBackendConfigSelection: deps.persistBackendConfigSelection, + configureBackendSettings: deps.configureBackendSettings, + BEHAVIOR_PANEL_KEYS: deps.BEHAVIOR_PANEL_KEYS, + THEME_PANEL_KEYS: deps.THEME_PANEL_KEYS, + }); +} diff --git a/test/unified-settings-entry.test.ts b/test/unified-settings-entry.test.ts new file mode 100644 index 00000000..ae473f76 --- /dev/null +++ b/test/unified-settings-entry.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; +import { configureUnifiedSettingsEntry } from "../lib/codex-manager/unified-settings-entry.js"; + +describe("unified settings entry", () => { + it("delegates to the unified settings controller with provided deps", async () => { + const configureUnifiedSettingsController = vi.fn(async () => ({ + menuShowStatusBadge: true, + })); + + const result = await configureUnifiedSettingsEntry(undefined, { + configureUnifiedSettingsController, + cloneDashboardSettings: vi.fn((settings) => settings), + cloneBackendPluginConfig: vi.fn((config) => config), + loadDashboardDisplaySettings: vi.fn(async () => ({ + menuShowStatusBadge: false, + })), + loadPluginConfig: vi.fn(() => ({ fetchTimeoutMs: 1000 })), + applyUiThemeFromDashboardSettings: vi.fn(), + promptSettingsHub: vi.fn(), + configureDashboardDisplaySettings: vi.fn(), + configureStatuslineSettings: vi.fn(), + promptBehaviorSettings: vi.fn(), + promptThemeSettings: vi.fn(), + dashboardSettingsEqual: vi.fn(), + persistDashboardSettingsSelection: vi.fn(), + promptExperimentalSettings: vi.fn(), + backendSettingsEqual: vi.fn(), + persistBackendConfigSelection: vi.fn(), + configureBackendSettings: vi.fn(), + BEHAVIOR_PANEL_KEYS: [], + THEME_PANEL_KEYS: [], + }); + + expect(configureUnifiedSettingsController).toHaveBeenCalled(); + expect(result).toEqual({ menuShowStatusBadge: true }); + }); +}); From c64566fc24a7f48591330f5181acc69c3b4cadd6 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 09:25:44 +0800 Subject: [PATCH 271/376] refactor: extract named backups list facade --- lib/storage.ts | 5 ++++- lib/storage/named-backups-entry.ts | 23 +++++++++++++++++++++++ test/named-backups-entry.test.ts | 23 +++++++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 lib/storage/named-backups-entry.ts create mode 100644 test/named-backups-entry.test.ts diff --git a/lib/storage.ts b/lib/storage.ts index 41e83034..e5aa56ad 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -61,6 +61,7 @@ import { collectNamedBackups, type NamedBackupSummary, } from "./storage/named-backups.js"; +import { getNamedBackupsEntry } from "./storage/named-backups-entry.js"; import { findProjectRoot, getConfigDir, @@ -794,7 +795,9 @@ export function buildNamedBackupPath(name: string): string { } export async function getNamedBackups(): Promise { - return collectNamedBackups(getStoragePath(), { + return getNamedBackupsEntry({ + getStoragePath, + collectNamedBackups, loadAccountsFromPath: (path) => loadAccountsFromPath(path, { normalizeAccountStorage, diff --git a/lib/storage/named-backups-entry.ts b/lib/storage/named-backups-entry.ts new file mode 100644 index 00000000..a3ce917d --- /dev/null +++ b/lib/storage/named-backups-entry.ts @@ -0,0 +1,23 @@ +import type { NamedBackupSummary } from "../storage.js"; + +export async function getNamedBackupsEntry(params: { + getStoragePath: () => string; + collectNamedBackups: ( + storagePath: string, + deps: { + loadAccountsFromPath: ( + path: string, + ) => Promise<{ normalized: { accounts: unknown[] } | null }>; + logDebug: (message: string, details: Record) => void; + }, + ) => Promise; + loadAccountsFromPath: ( + path: string, + ) => Promise<{ normalized: { accounts: unknown[] } | null }>; + logDebug: (message: string, details: Record) => void; +}): Promise { + return params.collectNamedBackups(params.getStoragePath(), { + loadAccountsFromPath: params.loadAccountsFromPath, + logDebug: params.logDebug, + }); +} diff --git a/test/named-backups-entry.test.ts b/test/named-backups-entry.test.ts new file mode 100644 index 00000000..09824015 --- /dev/null +++ b/test/named-backups-entry.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from "vitest"; +import { getNamedBackupsEntry } from "../lib/storage/named-backups-entry.js"; + +describe("named backups entry", () => { + it("passes storage path and dependencies through to named backup collection", async () => { + const collectNamedBackups = vi.fn(async () => []); + const loadAccountsFromPath = vi.fn(async () => ({ normalized: null })); + const logDebug = vi.fn(); + + const result = await getNamedBackupsEntry({ + getStoragePath: () => "/tmp/accounts.json", + collectNamedBackups, + loadAccountsFromPath, + logDebug, + }); + + expect(collectNamedBackups).toHaveBeenCalledWith("/tmp/accounts.json", { + loadAccountsFromPath, + logDebug, + }); + expect(result).toEqual([]); + }); +}); From 30ec6017b5884d84bb18dfe7eb941ea1c7b5d5f9 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 09:31:12 +0800 Subject: [PATCH 272/376] refactor: extract experimental target loader wrapper --- .../experimental-sync-target-entry.ts | 64 +++++++++++++++++++ lib/codex-manager/settings-hub.ts | 20 ++---- test/experimental-sync-target-entry.test.ts | 27 ++++++++ 3 files changed, 96 insertions(+), 15 deletions(-) create mode 100644 lib/codex-manager/experimental-sync-target-entry.ts create mode 100644 test/experimental-sync-target-entry.test.ts diff --git a/lib/codex-manager/experimental-sync-target-entry.ts b/lib/codex-manager/experimental-sync-target-entry.ts new file mode 100644 index 00000000..f72201a3 --- /dev/null +++ b/lib/codex-manager/experimental-sync-target-entry.ts @@ -0,0 +1,64 @@ +import type { AccountStorageV3 } from "../storage.js"; + +export async function loadExperimentalSyncTargetEntry(params: { + loadExperimentalSyncTargetState: (args: { + detectTarget: () => ReturnType< + typeof import("../oc-chatgpt-target-detection.js").detectOcChatgptMultiAuthTarget + >; + readJson: (path: string) => Promise; + normalizeAccountStorage: (value: unknown) => AccountStorageV3 | null; + }) => Promise< + | { + kind: "blocked-ambiguous"; + detection: ReturnType< + typeof import("../oc-chatgpt-target-detection.js").detectOcChatgptMultiAuthTarget + >; + } + | { + kind: "blocked-none"; + detection: ReturnType< + typeof import("../oc-chatgpt-target-detection.js").detectOcChatgptMultiAuthTarget + >; + } + | { kind: "error"; message: string } + | { + kind: "target"; + detection: ReturnType< + typeof import("../oc-chatgpt-target-detection.js").detectOcChatgptMultiAuthTarget + >; + destination: AccountStorageV3 | null; + } + >; + detectTarget: () => ReturnType< + typeof import("../oc-chatgpt-target-detection.js").detectOcChatgptMultiAuthTarget + >; + readFileWithRetry: ( + path: string, + options: { + retryableCodes: Set; + maxAttempts: number; + sleep: (ms: number) => Promise; + }, + ) => Promise; + normalizeAccountStorage: (value: unknown) => AccountStorageV3 | null; + sleep: (ms: number) => Promise; +}): ReturnType { + return params.loadExperimentalSyncTargetState({ + detectTarget: params.detectTarget, + readJson: async (path) => + JSON.parse( + await params.readFileWithRetry(path, { + retryableCodes: new Set([ + "EBUSY", + "EPERM", + "EAGAIN", + "ENOTEMPTY", + "EACCES", + ]), + maxAttempts: 4, + sleep: params.sleep, + }), + ), + normalizeAccountStorage: params.normalizeAccountStorage, + }); +} diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 4e1cc2cc..072e2cac 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -70,6 +70,7 @@ import { mapExperimentalStatusHotkey, } from "./experimental-settings-schema.js"; import { loadExperimentalSyncTargetState } from "./experimental-sync-target.js"; +import { loadExperimentalSyncTargetEntry } from "./experimental-sync-target-entry.js"; import { promptSettingsHubEntry } from "./settings-hub-entry.js"; import { buildSettingsHubItems, @@ -648,23 +649,12 @@ async function loadExperimentalSyncTarget(): Promise< destination: import("../storage.js").AccountStorageV3 | null; } > { - return loadExperimentalSyncTargetState({ + return loadExperimentalSyncTargetEntry({ + loadExperimentalSyncTargetState, detectTarget: detectOcChatgptMultiAuthTarget, - readJson: async (path) => - JSON.parse( - await readFileWithRetry(path, { - retryableCodes: new Set([ - "EBUSY", - "EPERM", - "EAGAIN", - "ENOTEMPTY", - "EACCES", - ]), - maxAttempts: 4, - sleep, - }), - ), + readFileWithRetry, normalizeAccountStorage, + sleep, }); } diff --git a/test/experimental-sync-target-entry.test.ts b/test/experimental-sync-target-entry.test.ts new file mode 100644 index 00000000..942740bb --- /dev/null +++ b/test/experimental-sync-target-entry.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vitest"; +import { loadExperimentalSyncTargetEntry } from "../lib/codex-manager/experimental-sync-target-entry.js"; + +describe("experimental sync target entry", () => { + it("delegates retrying file read and normalization through the target loader", async () => { + const loadExperimentalSyncTargetState = vi.fn(async () => ({ + kind: "target", + detection: { kind: "target" }, + destination: null, + })); + + const result = await loadExperimentalSyncTargetEntry({ + loadExperimentalSyncTargetState, + detectTarget: () => ({ kind: "target" }) as never, + readFileWithRetry: vi.fn(async () => "{}"), + normalizeAccountStorage: vi.fn(() => null), + sleep: vi.fn(async () => undefined), + }); + + expect(loadExperimentalSyncTargetState).toHaveBeenCalled(); + expect(result).toEqual({ + kind: "target", + detection: { kind: "target" }, + destination: null, + }); + }); +}); From 0606240e30683b9c23dc85cc769c163170e18302 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 09:39:42 +0800 Subject: [PATCH 273/376] refactor: extract backend category entry wrapper --- lib/codex-manager/backend-category-entry.ts | 187 ++++++++++++++++++++ lib/codex-manager/settings-hub.ts | 4 +- test/backend-category-entry.test.ts | 56 ++++++ 3 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 lib/codex-manager/backend-category-entry.ts create mode 100644 test/backend-category-entry.test.ts diff --git a/lib/codex-manager/backend-category-entry.ts b/lib/codex-manager/backend-category-entry.ts new file mode 100644 index 00000000..87ff9b3b --- /dev/null +++ b/lib/codex-manager/backend-category-entry.ts @@ -0,0 +1,187 @@ +import type { PluginConfig } from "../types.js"; +import type { + BackendCategoryOption, + BackendNumberSettingKey, + BackendNumberSettingOption, + BackendSettingFocusKey, + BackendToggleSettingKey, + BackendToggleSettingOption, +} from "./backend-settings-schema.js"; + +export async function promptBackendCategorySettingsEntry(params: { + initial: PluginConfig; + category: BackendCategoryOption; + initialFocus: BackendSettingFocusKey; + promptBackendCategorySettingsMenu: (args: { + initial: PluginConfig; + category: BackendCategoryOption; + initialFocus: BackendSettingFocusKey; + ui: ReturnType; + cloneBackendPluginConfig: (config: PluginConfig) => PluginConfig; + buildBackendSettingsPreview: ( + config: PluginConfig, + ui: ReturnType, + focusKey: BackendSettingFocusKey, + deps: { + highlightPreviewToken: ( + text: string, + ui: ReturnType, + ) => string; + }, + ) => { label: string; hint: string }; + highlightPreviewToken: ( + text: string, + ui: ReturnType, + ) => string; + resolveFocusedBackendNumberKey: ( + focus: BackendSettingFocusKey, + numberOptions: BackendNumberSettingOption[], + ) => BackendNumberSettingKey; + clampBackendNumber: ( + option: BackendNumberSettingOption, + value: number, + ) => number; + formatBackendNumberValue: ( + option: BackendNumberSettingOption, + value: number, + ) => string; + formatDashboardSettingState: (enabled: boolean) => string; + applyBackendCategoryDefaults: ( + config: PluginConfig, + selectedCategory: BackendCategoryOption, + ) => PluginConfig; + getBackendCategoryInitialFocus: ( + category: BackendCategoryOption, + ) => BackendSettingFocusKey; + backendDefaults: PluginConfig; + toggleOptionByKey: ReadonlyMap< + BackendToggleSettingKey, + BackendToggleSettingOption + >; + numberOptionByKey: ReadonlyMap< + BackendNumberSettingKey, + BackendNumberSettingOption + >; + select: ( + items: import("../ui/select.js").MenuItem[], + options: { + message: string; + subtitle: string; + help: string; + clearScreen: boolean; + theme: ReturnType< + typeof import("../ui/runtime.js").getUiRuntimeOptions + >["theme"]; + selectedEmphasis: "minimal"; + initialCursor?: number; + onCursorChange: (event: { cursor: number }) => void; + onInput: (raw: string) => T | undefined; + }, + ) => Promise; + copy: { + previewHeading: string; + backendToggleHeading: string; + backendNumberHeading: string; + backendDecrease: string; + backendIncrease: string; + backendResetCategory: string; + backendBackToCategories: string; + backendCategoryTitle: string; + backendCategoryHelp: string; + }; + }) => Promise<{ draft: PluginConfig; focusKey: BackendSettingFocusKey }>; + ui: ReturnType; + cloneBackendPluginConfig: (config: PluginConfig) => PluginConfig; + buildBackendSettingsPreview: ( + config: PluginConfig, + ui: ReturnType, + focusKey: BackendSettingFocusKey, + deps: { + highlightPreviewToken: ( + text: string, + ui: ReturnType, + ) => string; + }, + ) => { label: string; hint: string }; + highlightPreviewToken: ( + text: string, + ui: ReturnType, + ) => string; + resolveFocusedBackendNumberKey: ( + focus: BackendSettingFocusKey, + numberOptions: BackendNumberSettingOption[], + ) => BackendNumberSettingKey; + clampBackendNumber: ( + option: BackendNumberSettingOption, + value: number, + ) => number; + formatBackendNumberValue: ( + option: BackendNumberSettingOption, + value: number, + ) => string; + formatDashboardSettingState: (enabled: boolean) => string; + applyBackendCategoryDefaults: ( + config: PluginConfig, + selectedCategory: BackendCategoryOption, + ) => PluginConfig; + getBackendCategoryInitialFocus: ( + category: BackendCategoryOption, + ) => BackendSettingFocusKey; + backendDefaults: PluginConfig; + toggleOptionByKey: ReadonlyMap< + BackendToggleSettingKey, + BackendToggleSettingOption + >; + numberOptionByKey: ReadonlyMap< + BackendNumberSettingKey, + BackendNumberSettingOption + >; + select: ( + items: import("../ui/select.js").MenuItem[], + options: { + message: string; + subtitle: string; + help: string; + clearScreen: boolean; + theme: ReturnType< + typeof import("../ui/runtime.js").getUiRuntimeOptions + >["theme"]; + selectedEmphasis: "minimal"; + initialCursor?: number; + onCursorChange: (event: { cursor: number }) => void; + onInput: (raw: string) => T | undefined; + }, + ) => Promise; + copy: { + previewHeading: string; + backendToggleHeading: string; + backendNumberHeading: string; + backendDecrease: string; + backendIncrease: string; + backendResetCategory: string; + backendBackToCategories: string; + backendCategoryTitle: string; + backendCategoryHelp: string; + }; +}): Promise<{ draft: PluginConfig; focusKey: BackendSettingFocusKey }> { + return params.promptBackendCategorySettingsMenu({ + initial: params.initial, + category: params.category, + initialFocus: params.initialFocus, + ui: params.ui, + cloneBackendPluginConfig: params.cloneBackendPluginConfig, + buildBackendSettingsPreview: params.buildBackendSettingsPreview, + highlightPreviewToken: params.highlightPreviewToken, + resolveFocusedBackendNumberKey: params.resolveFocusedBackendNumberKey, + clampBackendNumber: params.clampBackendNumber, + formatBackendNumberValue: params.formatBackendNumberValue, + formatDashboardSettingState: params.formatDashboardSettingState, + applyBackendCategoryDefaults: params.applyBackendCategoryDefaults, + getBackendCategoryInitialFocus: params.getBackendCategoryInitialFocus, + backendDefaults: params.backendDefaults, + toggleOptionByKey: params.toggleOptionByKey, + numberOptionByKey: params.numberOptionByKey, + select: params.select, + copy: params.copy, + }); +} diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 072e2cac..a1baa02f 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -22,6 +22,7 @@ import { UI_COPY } from "../ui/copy.js"; import { getUiRuntimeOptions, setUiRuntimeOptions } from "../ui/runtime.js"; import { select } from "../ui/select.js"; import { sleep } from "../utils.js"; +import { promptBackendCategorySettingsEntry } from "./backend-category-entry.js"; import { applyBackendCategoryDefaults, getBackendCategory, @@ -587,10 +588,11 @@ async function promptBackendCategorySettings( category: BackendCategoryOption, initialFocus: BackendSettingFocusKey, ): Promise<{ draft: PluginConfig; focusKey: BackendSettingFocusKey }> { - return promptBackendCategorySettingsMenu({ + return promptBackendCategorySettingsEntry({ initial, category, initialFocus, + promptBackendCategorySettingsMenu, ui: getUiRuntimeOptions(), cloneBackendPluginConfig, buildBackendSettingsPreview, diff --git a/test/backend-category-entry.test.ts b/test/backend-category-entry.test.ts new file mode 100644 index 00000000..f87bd402 --- /dev/null +++ b/test/backend-category-entry.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vitest"; +import { promptBackendCategorySettingsEntry } from "../lib/codex-manager/backend-category-entry.js"; + +describe("backend category entry", () => { + it("passes category wiring through to the backend category prompt helper", async () => { + const promptBackendCategorySettingsMenu = vi.fn(async () => ({ + draft: { fetchTimeoutMs: 1000 }, + focusKey: null, + })); + + const result = await promptBackendCategorySettingsEntry({ + initial: { fetchTimeoutMs: 2000 }, + category: { + key: "session-sync", + label: "Session Sync", + description: "desc", + } as never, + initialFocus: null, + promptBackendCategorySettingsMenu, + ui: { theme: {} } as never, + cloneBackendPluginConfig: vi.fn((config) => config), + buildBackendSettingsPreview: vi.fn(() => ({ + label: "Preview", + hint: "Hint", + })), + highlightPreviewToken: vi.fn((text) => text), + resolveFocusedBackendNumberKey: vi.fn(() => "fetchTimeoutMs" as never), + clampBackendNumber: vi.fn((_, value) => value), + formatBackendNumberValue: vi.fn((_, value) => String(value)), + formatDashboardSettingState: vi.fn((enabled) => (enabled ? "on" : "off")), + applyBackendCategoryDefaults: vi.fn((config) => config), + getBackendCategoryInitialFocus: vi.fn(() => null), + backendDefaults: { fetchTimeoutMs: 1000 }, + toggleOptionByKey: new Map(), + numberOptionByKey: new Map(), + select: vi.fn(), + copy: { + previewHeading: "Preview", + backendToggleHeading: "Toggles", + backendNumberHeading: "Numbers", + backendDecrease: "Decrease", + backendIncrease: "Increase", + backendResetCategory: "Reset", + backendBackToCategories: "Back", + backendCategoryTitle: "Category", + backendCategoryHelp: "Help", + }, + }); + + expect(promptBackendCategorySettingsMenu).toHaveBeenCalled(); + expect(result).toEqual({ + draft: { fetchTimeoutMs: 1000 }, + focusKey: null, + }); + }); +}); From 5e3562ca651c32c7c82b81b26e0128897c22e1fd Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 09:48:05 +0800 Subject: [PATCH 274/376] refactor: extract experimental settings entry wrapper --- .../experimental-settings-entry.ts | 114 ++++++++++++++++++ lib/codex-manager/settings-hub.ts | 4 +- test/experimental-settings-entry.test.ts | 42 +++++++ 3 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 lib/codex-manager/experimental-settings-entry.ts create mode 100644 test/experimental-settings-entry.test.ts diff --git a/lib/codex-manager/experimental-settings-entry.ts b/lib/codex-manager/experimental-settings-entry.ts new file mode 100644 index 00000000..76f4e468 --- /dev/null +++ b/lib/codex-manager/experimental-settings-entry.ts @@ -0,0 +1,114 @@ +import type { PluginConfig } from "../types.js"; + +export async function promptExperimentalSettingsEntry(params: { + initialConfig: PluginConfig; + promptExperimentalSettingsMenu: (args: { + initialConfig: PluginConfig; + isInteractive: () => boolean; + ui: ReturnType; + cloneBackendPluginConfig: (config: PluginConfig) => PluginConfig; + select: ( + items: Array>, + options: Record, + ) => Promise; + getExperimentalSelectOptions: ( + ui: ReturnType, + help: string, + hotkeyMapper: (raw: string) => unknown, + ) => Record; + mapExperimentalMenuHotkey: (raw: string) => unknown; + mapExperimentalStatusHotkey: (raw: string) => unknown; + formatDashboardSettingState: (enabled: boolean) => string; + copy: Record; + input: NodeJS.ReadStream; + output: NodeJS.WriteStream; + runNamedBackupExport: (args: { + name: string; + }) => Promise<{ kind: string; path?: string; error?: unknown }>; + loadAccounts: () => Promise; + loadExperimentalSyncTarget: () => Promise; + planOcChatgptSync: (args: Record) => Promise; + applyOcChatgptSync: (args: Record) => Promise; + getTargetKind: (targetState: unknown) => string; + getTargetDestination: (targetState: unknown) => unknown; + getTargetDetection: (targetState: unknown) => unknown; + getTargetErrorMessage: (targetState: unknown) => string | null; + getPlanKind: (plan: unknown) => string; + getPlanBlockedReason: (plan: unknown) => string; + getPlanPreview: (plan: unknown) => { + toAdd: unknown[]; + toUpdate: unknown[]; + toSkip: unknown[]; + unchangedDestinationOnly: unknown[]; + activeSelectionBehavior: string; + }; + getAppliedLabel: (applied: unknown) => { label: string; color: string }; + }) => Promise; + isInteractive: () => boolean; + ui: ReturnType; + cloneBackendPluginConfig: (config: PluginConfig) => PluginConfig; + select: ( + items: Array>, + options: Record, + ) => Promise; + getExperimentalSelectOptions: ( + ui: ReturnType, + help: string, + hotkeyMapper: (raw: string) => unknown, + ) => Record; + mapExperimentalMenuHotkey: (raw: string) => unknown; + mapExperimentalStatusHotkey: (raw: string) => unknown; + formatDashboardSettingState: (enabled: boolean) => string; + copy: Record; + input: NodeJS.ReadStream; + output: NodeJS.WriteStream; + runNamedBackupExport: (args: { + name: string; + }) => Promise<{ kind: string; path?: string; error?: unknown }>; + loadAccounts: () => Promise; + loadExperimentalSyncTarget: () => Promise; + planOcChatgptSync: (args: Record) => Promise; + applyOcChatgptSync: (args: Record) => Promise; + getTargetKind: (targetState: unknown) => string; + getTargetDestination: (targetState: unknown) => unknown; + getTargetDetection: (targetState: unknown) => unknown; + getTargetErrorMessage: (targetState: unknown) => string | null; + getPlanKind: (plan: unknown) => string; + getPlanBlockedReason: (plan: unknown) => string; + getPlanPreview: (plan: unknown) => { + toAdd: unknown[]; + toUpdate: unknown[]; + toSkip: unknown[]; + unchangedDestinationOnly: unknown[]; + activeSelectionBehavior: string; + }; + getAppliedLabel: (applied: unknown) => { label: string; color: string }; +}): Promise { + return params.promptExperimentalSettingsMenu({ + initialConfig: params.initialConfig, + isInteractive: params.isInteractive, + ui: params.ui, + cloneBackendPluginConfig: params.cloneBackendPluginConfig, + select: params.select, + getExperimentalSelectOptions: params.getExperimentalSelectOptions, + mapExperimentalMenuHotkey: params.mapExperimentalMenuHotkey, + mapExperimentalStatusHotkey: params.mapExperimentalStatusHotkey, + formatDashboardSettingState: params.formatDashboardSettingState, + copy: params.copy, + input: params.input, + output: params.output, + runNamedBackupExport: params.runNamedBackupExport, + loadAccounts: params.loadAccounts, + loadExperimentalSyncTarget: params.loadExperimentalSyncTarget, + planOcChatgptSync: params.planOcChatgptSync, + applyOcChatgptSync: params.applyOcChatgptSync, + getTargetKind: params.getTargetKind, + getTargetDestination: params.getTargetDestination, + getTargetDetection: params.getTargetDetection, + getTargetErrorMessage: params.getTargetErrorMessage, + getPlanKind: params.getPlanKind, + getPlanBlockedReason: params.getPlanBlockedReason, + getPlanPreview: params.getPlanPreview, + getAppliedLabel: params.getAppliedLabel, + }); +} diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index a1baa02f..11d7628e 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -64,6 +64,7 @@ import { dashboardSettingsDataEqual, } from "./dashboard-settings-data.js"; import { configureDashboardSettingsEntry } from "./dashboard-settings-entry.js"; +import { promptExperimentalSettingsEntry } from "./experimental-settings-entry.js"; import { promptExperimentalSettingsMenu } from "./experimental-settings-prompt.js"; import { getExperimentalSelectOptions, @@ -663,8 +664,9 @@ async function loadExperimentalSyncTarget(): Promise< async function promptExperimentalSettings( initialConfig: PluginConfig, ): Promise { - return promptExperimentalSettingsMenu({ + return promptExperimentalSettingsEntry({ initialConfig, + promptExperimentalSettingsMenu: promptExperimentalSettingsMenu as never, isInteractive: () => input.isTTY && output.isTTY, ui: getUiRuntimeOptions(), cloneBackendPluginConfig, diff --git a/test/experimental-settings-entry.test.ts b/test/experimental-settings-entry.test.ts new file mode 100644 index 00000000..9936553c --- /dev/null +++ b/test/experimental-settings-entry.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from "vitest"; +import { promptExperimentalSettingsEntry } from "../lib/codex-manager/experimental-settings-entry.js"; + +describe("experimental settings entry", () => { + it("passes all dependencies through to the experimental settings prompt helper", async () => { + const promptExperimentalSettingsMenu = vi.fn(async () => ({ + fetchTimeoutMs: 1000, + })); + + const result = await promptExperimentalSettingsEntry({ + initialConfig: { fetchTimeoutMs: 2000 }, + promptExperimentalSettingsMenu, + isInteractive: () => true, + ui: { theme: {} } as never, + cloneBackendPluginConfig: vi.fn((config) => config), + select: vi.fn(), + getExperimentalSelectOptions: vi.fn(() => ({})), + mapExperimentalMenuHotkey: vi.fn(), + mapExperimentalStatusHotkey: vi.fn(), + formatDashboardSettingState: vi.fn((enabled) => (enabled ? "on" : "off")), + copy: {}, + input: process.stdin, + output: process.stdout, + runNamedBackupExport: vi.fn(), + loadAccounts: vi.fn(), + loadExperimentalSyncTarget: vi.fn(), + planOcChatgptSync: vi.fn(), + applyOcChatgptSync: vi.fn(), + getTargetKind: vi.fn(), + getTargetDestination: vi.fn(), + getTargetDetection: vi.fn(), + getTargetErrorMessage: vi.fn(), + getPlanKind: vi.fn(), + getPlanBlockedReason: vi.fn(), + getPlanPreview: vi.fn(), + getAppliedLabel: vi.fn(), + }); + + expect(promptExperimentalSettingsMenu).toHaveBeenCalled(); + expect(result).toEqual({ fetchTimeoutMs: 1000 }); + }); +}); From a011e81beda27a43c076bc8e4bafc00ea64c9a08 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 09:58:14 +0800 Subject: [PATCH 275/376] refactor: add package subpath exports --- lib/auth/index.ts | 1 + lib/codex-cli/index.ts | 4 + lib/request/index.ts | 6 ++ package.json | 33 ++++++++ test/public-api-contract.test.ts | 137 +++++++++++++++++++++++++------ 5 files changed, 154 insertions(+), 27 deletions(-) create mode 100644 lib/auth/index.ts create mode 100644 lib/codex-cli/index.ts create mode 100644 lib/request/index.ts diff --git a/lib/auth/index.ts b/lib/auth/index.ts new file mode 100644 index 00000000..d12b110c --- /dev/null +++ b/lib/auth/index.ts @@ -0,0 +1 @@ +export * from "./auth.js"; diff --git a/lib/codex-cli/index.ts b/lib/codex-cli/index.ts new file mode 100644 index 00000000..a9850ef1 --- /dev/null +++ b/lib/codex-cli/index.ts @@ -0,0 +1,4 @@ +export * from "./observability.js"; +export * from "./state.js"; +export * from "./sync.js"; +export * from "./writer.js"; diff --git a/lib/request/index.ts b/lib/request/index.ts new file mode 100644 index 00000000..076c0643 --- /dev/null +++ b/lib/request/index.ts @@ -0,0 +1,6 @@ +export * from "./failure-policy.js"; +export * from "./fetch-helpers.js"; +export * from "./rate-limit-backoff.js"; +export * from "./request-transformer.js"; +export * from "./response-handler.js"; +export * from "./stream-failover.js"; diff --git a/package.json b/package.json index e4bae7b8..bff68ee8 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,39 @@ "description": "Multi-account OAuth manager and codex auth wrapper for the official @openai/codex CLI, with switching, health checks, and recovery tools", "main": "./dist/index.js", "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./auth": { + "types": "./dist/lib/auth/index.d.ts", + "import": "./dist/lib/auth/index.js", + "default": "./dist/lib/auth/index.js" + }, + "./storage": { + "types": "./dist/lib/storage.d.ts", + "import": "./dist/lib/storage.js", + "default": "./dist/lib/storage.js" + }, + "./config": { + "types": "./dist/lib/config.d.ts", + "import": "./dist/lib/config.js", + "default": "./dist/lib/config.js" + }, + "./request": { + "types": "./dist/lib/request/index.d.ts", + "import": "./dist/lib/request/index.js", + "default": "./dist/lib/request/index.js" + }, + "./cli": { + "types": "./dist/lib/codex-cli/index.d.ts", + "import": "./dist/lib/codex-cli/index.js", + "default": "./dist/lib/codex-cli/index.js" + }, + "./package.json": "./package.json" + }, "type": "module", "license": "MIT", "author": "ndycode", diff --git a/test/public-api-contract.test.ts b/test/public-api-contract.test.ts index 307093f3..f81e60ae 100644 --- a/test/public-api-contract.test.ts +++ b/test/public-api-contract.test.ts @@ -1,10 +1,4 @@ import { describe, expect, it, vi } from "vitest"; -import { - HealthScoreTracker, - TokenBucketTracker, - exponentialBackoff, - selectHybridAccount, -} from "../lib/rotation.js"; import { getTopCandidates } from "../lib/parallel-probe.js"; import { createCodexHeaders } from "../lib/request/fetch-helpers.js"; import { @@ -12,7 +6,14 @@ import { getRateLimitBackoffWithReason, } from "../lib/request/rate-limit-backoff.js"; import { transformRequestBody } from "../lib/request/request-transformer.js"; +import { + exponentialBackoff, + HealthScoreTracker, + selectHybridAccount, + TokenBucketTracker, +} from "../lib/rotation.js"; import type { RequestBody } from "../lib/types.js"; +import pkg from "../package.json" with { type: "json" }; describe("public api contract", () => { it("keeps root plugin exports aligned", async () => { @@ -26,37 +27,106 @@ describe("public api contract", () => { const rotation = await import("../lib/rotation.js"); const parallelProbe = await import("../lib/parallel-probe.js"); const fetchHelpers = await import("../lib/request/fetch-helpers.js"); - const rateLimitBackoff = await import("../lib/request/rate-limit-backoff.js"); - const requestTransformer = await import("../lib/request/request-transformer.js"); - const required: ReadonlyArray< - readonly [string, Record] - > = [ - ["selectHybridAccount", rotation], - ["exponentialBackoff", rotation], - ["getTopCandidates", parallelProbe], - ["createCodexHeaders", fetchHelpers], - ["getRateLimitBackoffWithReason", rateLimitBackoff], - ["transformRequestBody", requestTransformer], - ]; + const rateLimitBackoff = await import( + "../lib/request/rate-limit-backoff.js" + ); + const requestTransformer = await import( + "../lib/request/request-transformer.js" + ); + const required: ReadonlyArray]> = + [ + ["selectHybridAccount", rotation], + ["exponentialBackoff", rotation], + ["getTopCandidates", parallelProbe], + ["createCodexHeaders", fetchHelpers], + ["getRateLimitBackoffWithReason", rateLimitBackoff], + ["transformRequestBody", requestTransformer], + ]; for (const [name, mod] of required) { expect(name in mod, `missing export: ${name}`).toBe(true); expect(typeof mod[name], `${name} should be a function`).toBe("function"); } }); + it("declares the supported package subpath exports", async () => { + expect(pkg.exports).toEqual({ + ".": { + types: "./dist/index.d.ts", + import: "./dist/index.js", + default: "./dist/index.js", + }, + "./auth": { + types: "./dist/lib/auth/index.d.ts", + import: "./dist/lib/auth/index.js", + default: "./dist/lib/auth/index.js", + }, + "./storage": { + types: "./dist/lib/storage.d.ts", + import: "./dist/lib/storage.js", + default: "./dist/lib/storage.js", + }, + "./config": { + types: "./dist/lib/config.d.ts", + import: "./dist/lib/config.js", + default: "./dist/lib/config.js", + }, + "./request": { + types: "./dist/lib/request/index.d.ts", + import: "./dist/lib/request/index.js", + default: "./dist/lib/request/index.js", + }, + "./cli": { + types: "./dist/lib/codex-cli/index.d.ts", + import: "./dist/lib/codex-cli/index.js", + default: "./dist/lib/codex-cli/index.js", + }, + "./package.json": "./package.json", + }); + }); + + it("keeps the supported subpath entry barrels aligned", async () => { + const auth = await import("../lib/auth/index.js"); + const storage = await import("../lib/storage.js"); + const config = await import("../lib/config.js"); + const request = await import("../lib/request/index.js"); + const cli = await import("../lib/codex-cli/index.js"); + + expect(typeof auth.exchangeAuthorizationCode).toBe("function"); + expect(typeof storage.loadAccounts).toBe("function"); + expect(typeof config.loadPluginConfig).toBe("function"); + expect(typeof request.createCodexHeaders).toBe("function"); + expect(typeof request.transformRequestBody).toBe("function"); + expect(typeof cli.loadCodexCliState).toBe("function"); + }); + it("keeps positional and options-object overload behavior aligned", async () => { const healthTracker = new HealthScoreTracker(); const tokenTracker = new TokenBucketTracker(); - const accounts = [{ index: 0, isAvailable: true, lastUsed: 1_709_280_000_000 }]; + const accounts = [ + { index: 0, isAvailable: true, lastUsed: 1_709_280_000_000 }, + ]; - const selectedPositional = selectHybridAccount(accounts, healthTracker, tokenTracker); - const selectedNamed = selectHybridAccount({ accounts, healthTracker, tokenTracker }); + const selectedPositional = selectHybridAccount( + accounts, + healthTracker, + tokenTracker, + ); + const selectedNamed = selectHybridAccount({ + accounts, + healthTracker, + tokenTracker, + }); expect(selectedNamed?.index).toBe(selectedPositional?.index); const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5); try { const backoffPositional = exponentialBackoff(3, 1000, 60000, 0); - const backoffNamed = exponentialBackoff({ attempt: 3, baseMs: 1000, maxMs: 60000, jitterFactor: 0 }); + const backoffNamed = exponentialBackoff({ + attempt: 3, + baseMs: 1000, + maxMs: 60000, + jitterFactor: 0, + }); expect(backoffNamed).toBe(backoffPositional); } finally { randomSpy.mockRestore(); @@ -82,7 +152,9 @@ describe("public api contract", () => { 1, ); const topNamed = getTopCandidates({ - accountManager: manager as unknown as Parameters[0], + accountManager: manager as unknown as Parameters< + typeof getTopCandidates + >[0], modelFamily: "codex", model: null, maxCandidates: 1, @@ -99,11 +171,22 @@ describe("public api contract", () => { accessToken: "token", opts: { model: "gpt-5", promptCacheKey: "session-compat" }, }); - expect(headersNamed.get("Authorization")).toBe(headersPositional.get("Authorization")); - expect(headersNamed.get("conversation_id")).toBe(headersPositional.get("conversation_id")); - expect(headersNamed.get("session_id")).toBe(headersPositional.get("session_id")); + expect(headersNamed.get("Authorization")).toBe( + headersPositional.get("Authorization"), + ); + expect(headersNamed.get("conversation_id")).toBe( + headersPositional.get("conversation_id"), + ); + expect(headersNamed.get("session_id")).toBe( + headersPositional.get("session_id"), + ); - const ratePositional = getRateLimitBackoffWithReason(1, "compat", 1000, "tokens"); + const ratePositional = getRateLimitBackoffWithReason( + 1, + "compat", + 1000, + "tokens", + ); clearRateLimitBackoffState(); const rateNamed = getRateLimitBackoffWithReason({ accountIndex: 1, From b85ac454047644cfa00e94abce8a170588bd168c Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 10:28:36 +0800 Subject: [PATCH 276/376] feat: add forecast explain output --- lib/codex-manager.ts | 1355 ++++++++++++++++++++++---------- lib/forecast.ts | 135 +++- test/codex-manager-cli.test.ts | 627 ++++++++++----- 3 files changed, 1468 insertions(+), 649 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index b80f1204..bf00c254 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -1,16 +1,7 @@ -import { createInterface } from "node:readline/promises"; -import { stdin as input, stdout as output } from "node:process"; -import { promises as fs, existsSync } from "node:fs"; +import { existsSync, promises as fs } from "node:fs"; import { dirname, resolve } from "node:path"; -import { - createAuthorizationFlow, - exchangeAuthorizationCode, - parseAuthorizationInput, - REDIRECT_URI, -} from "./auth/auth.js"; -import { startLocalOAuthServer } from "./auth/server.js"; -import { copyTextToClipboard, isBrowserLaunchSuppressed, openBrowserUrl } from "./auth/browser.js"; -import { promptAddAnotherAccount, promptLoginMode, type ExistingAccountInfo } from "./cli.js"; +import { stdin as input, stdout as output } from "node:process"; +import { createInterface } from "node:readline/promises"; import { extractAccountEmail, extractAccountId, @@ -23,68 +14,90 @@ import { selectBestAccountCandidate, shouldUpdateAccountIdFromToken, } from "./accounts.js"; +import { + createAuthorizationFlow, + exchangeAuthorizationCode, + parseAuthorizationInput, + REDIRECT_URI, +} from "./auth/auth.js"; +import { + copyTextToClipboard, + isBrowserLaunchSuppressed, + openBrowserUrl, +} from "./auth/browser.js"; +import { startLocalOAuthServer } from "./auth/server.js"; +import { + type ExistingAccountInfo, + promptAddAnotherAccount, + promptLoginMode, +} from "./cli.js"; +import { + getCodexCliAuthPath, + getCodexCliConfigPath, + loadCodexCliState, +} from "./codex-cli/state.js"; +import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; +import { + applyUiThemeFromDashboardSettings, + configureUnifiedSettings, + resolveMenuLayoutMode, +} from "./codex-manager/settings-hub.js"; import { ACCOUNT_LIMITS } from "./constants.js"; import { - loadDashboardDisplaySettings, - DEFAULT_DASHBOARD_DISPLAY_SETTINGS, - type DashboardDisplaySettings, type DashboardAccountSortMode, + type DashboardDisplaySettings, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS, + loadDashboardDisplaySettings, } from "./dashboard-settings.js"; import { + buildForecastExplanation, evaluateForecastAccounts, + type ForecastAccountResult, isHardRefreshFailure, recommendForecastAccount, summarizeForecast, - type ForecastAccountResult, } from "./forecast.js"; import { createLogger } from "./logger.js"; import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; -import { - fetchCodexQuotaSnapshot, - formatQuotaSnapshotLine, - type CodexQuotaSnapshot, -} from "./quota-probe.js"; -import { queuedRefresh } from "./refresh-queue.js"; import { loadQuotaCache, - saveQuotaCache, type QuotaCacheData, type QuotaCacheEntry, + saveQuotaCache, } from "./quota-cache.js"; import { + type CodexQuotaSnapshot, + fetchCodexQuotaSnapshot, + formatQuotaSnapshotLine, +} from "./quota-probe.js"; +import { queuedRefresh } from "./refresh-queue.js"; +import { + type AccountMetadataV3, + type AccountStorageV3, clearAccounts, + type FlaggedAccountMetadataV1, findMatchingAccountIndex, formatStorageErrorHint, getNamedBackups, getStoragePath, - loadFlaggedAccounts, loadAccounts, - StorageError, + loadFlaggedAccounts, type NamedBackupSummary, restoreAccountsFromBackup, - saveFlaggedAccounts, + StorageError, saveAccounts, + saveFlaggedAccounts, setStoragePath, withAccountAndFlaggedStorageTransaction, withAccountStorageTransaction, - type AccountMetadataV3, - type AccountStorageV3, - type FlaggedAccountMetadataV1, } from "./storage.js"; import type { AccountIdSource, TokenFailure, TokenResult } from "./types.js"; -import { - getCodexCliAuthPath, - getCodexCliConfigPath, - loadCodexCliState, -} from "./codex-cli/state.js"; -import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; import { ANSI } from "./ui/ansi.js"; -import { UI_COPY } from "./ui/copy.js"; import { confirm } from "./ui/confirm.js"; +import { UI_COPY } from "./ui/copy.js"; import { paintUiText, quotaToneFromLeftPercent } from "./ui/format.js"; import { getUiRuntimeOptions } from "./ui/runtime.js"; -import { select, type MenuItem } from "./ui/select.js"; -import { applyUiThemeFromDashboardSettings, configureUnifiedSettings, resolveMenuLayoutMode } from "./codex-manager/settings-hub.js"; +import { type MenuItem, select } from "./ui/select.js"; type TokenSuccess = Extract; type TokenSuccessWithAccount = TokenSuccess & { @@ -105,15 +118,16 @@ function stylePromptText(text: string, tone: PromptTone): string { const mapped = tone === "accent" ? "primary" : tone; return paintUiText(ui, text, mapped); } - const legacyCode = tone === "accent" - ? ANSI.green - : tone === "success" + const legacyCode = + tone === "accent" ? ANSI.green - : tone === "warning" - ? ANSI.yellow - : tone === "danger" - ? ANSI.red - : ANSI.dim; + : tone === "success" + ? ANSI.green + : tone === "warning" + ? ANSI.yellow + : tone === "danger" + ? ANSI.red + : ANSI.dim; return `${legacyCode}${text}${ANSI.reset}`; } @@ -131,14 +145,17 @@ function extractErrorMessageFromPayload(payload: unknown): string | undefined { if (!payload || typeof payload !== "object") return undefined; const record = payload as Record; - const directMessage = typeof record.message === "string" - ? collapseWhitespace(record.message) - : ""; - const directCode = typeof record.code === "string" - ? collapseWhitespace(record.code) - : ""; + const directMessage = + typeof record.message === "string" + ? collapseWhitespace(record.message) + : ""; + const directCode = + typeof record.code === "string" ? collapseWhitespace(record.code) : ""; if (directMessage) { - if (directCode && !directMessage.toLowerCase().includes(directCode.toLowerCase())) { + if ( + directCode && + !directMessage.toLowerCase().includes(directCode.toLowerCase()) + ) { return `${directMessage} [${directCode}]`; } return directMessage; @@ -181,7 +198,8 @@ function normalizeFailureDetail( const raw = message?.trim() || reasonLabel || "refresh failed"; const structured = parseStructuredErrorMessage(raw); const normalized = collapseWhitespace(structured ?? raw); - const bounded = normalized.length > 260 ? `${normalized.slice(0, 257)}...` : normalized; + const bounded = + normalized.length > 260 ? `${normalized.slice(0, 257)}...` : normalized; return bounded.length > 0 ? bounded : "refresh failed"; } @@ -194,14 +212,19 @@ function joinStyledSegments(parts: string[]): string { function formatResultSummary( segments: ReadonlyArray<{ text: string; tone: PromptTone }>, ): string { - const rendered = segments.map((segment) => stylePromptText(segment.text, segment.tone)); + const rendered = segments.map((segment) => + stylePromptText(segment.text, segment.tone), + ); return `${stylePromptText("Result:", "accent")} ${joinStyledSegments(rendered)}`; } function styleQuotaSummary(summary: string): string { const normalized = collapseWhitespace(summary); if (!normalized) return stylePromptText(summary, "muted"); - const segments = normalized.split("|").map((segment) => segment.trim()).filter(Boolean); + const segments = normalized + .split("|") + .map((segment) => segment.trim()) + .filter(Boolean); if (segments.length === 0) return stylePromptText(normalized, "muted"); const rendered = segments.map((segment) => { @@ -224,7 +247,10 @@ function styleQuotaSummary(summary: string): string { return joinStyledSegments(rendered); } -function styleAccountDetailText(detail: string, fallbackTone: PromptTone = "muted"): string { +function styleAccountDetailText( + detail: string, + fallbackTone: PromptTone = "muted", +): string { const compact = collapseWhitespace(detail); if (!compact) return stylePromptText("", fallbackTone); @@ -239,11 +265,12 @@ function styleAccountDetailText(detail: string, fallbackTone: PromptTone = "mute : /ok|working|succeeded|valid/i.test(prefix) ? "success" : fallbackTone; - const suffixTone: PromptTone = /re-login|stale|warning|retry|fallback/i.test(suffix) - ? "warning" - : /failed|error/i.test(suffix) - ? "danger" - : "muted"; + const suffixTone: PromptTone = + /re-login|stale|warning|retry|fallback/i.test(suffix) + ? "warning" + : /failed|error/i.test(suffix) + ? "danger" + : "muted"; const chunks: string[] = []; if (prefix) chunks.push(stylePromptText(prefix, prefixTone)); @@ -253,13 +280,17 @@ function styleAccountDetailText(detail: string, fallbackTone: PromptTone = "mute } if (/rate-limited/i.test(compact)) return stylePromptText(compact, "danger"); - if (/re-login|stale|warning|fallback/i.test(compact)) return stylePromptText(compact, "warning"); + if (/re-login|stale|warning|fallback/i.test(compact)) + return stylePromptText(compact, "warning"); if (/failed|error/i.test(compact)) return stylePromptText(compact, "danger"); - if (/ok|working|succeeded|valid/i.test(compact)) return stylePromptText(compact, "success"); + if (/ok|working|succeeded|valid/i.test(compact)) + return stylePromptText(compact, "success"); return stylePromptText(compact, fallbackTone); } -function riskTone(level: ForecastAccountResult["riskLevel"]): "success" | "warning" | "danger" { +function riskTone( + level: ForecastAccountResult["riskLevel"], +): "success" | "warning" | "danger" { if (level === "low") return "success"; if (level === "medium") return "warning"; return "danger"; @@ -414,7 +445,8 @@ function resolveActiveIndex( ): number { const total = storage.accounts.length; if (total === 0) return 0; - const rawCandidate = storage.activeIndexByFamily?.[family] ?? storage.activeIndex; + const rawCandidate = + storage.activeIndexByFamily?.[family] ?? storage.activeIndex; const raw = Number.isFinite(rawCandidate) ? rawCandidate : 0; return Math.max(0, Math.min(raw, total - 1)); } @@ -545,7 +577,9 @@ function quotaCacheEntryToSnapshot(entry: QuotaCacheEntry): CodexQuotaSnapshot { }; } -function formatCompactQuotaWindowLabel(windowMinutes: number | undefined): string { +function formatCompactQuotaWindowLabel( + windowMinutes: number | undefined, +): string { if (!windowMinutes || !Number.isFinite(windowMinutes) || windowMinutes <= 0) { return "quota"; } @@ -554,7 +588,10 @@ function formatCompactQuotaWindowLabel(windowMinutes: number | undefined): strin return `${windowMinutes}m`; } -function formatCompactQuotaPart(windowMinutes: number | undefined, usedPercent: number | undefined): string | null { +function formatCompactQuotaPart( + windowMinutes: number | undefined, + usedPercent: number | undefined, +): string | null { const label = formatCompactQuotaWindowLabel(windowMinutes); if (typeof usedPercent !== "number" || !Number.isFinite(usedPercent)) { return null; @@ -563,7 +600,9 @@ function formatCompactQuotaPart(windowMinutes: number | undefined, usedPercent: return `${label} ${left}%`; } -function quotaLeftPercentFromUsed(usedPercent: number | undefined): number | undefined { +function quotaLeftPercentFromUsed( + usedPercent: number | undefined, +): number | undefined { if (typeof usedPercent !== "number" || !Number.isFinite(usedPercent)) { return undefined; } @@ -572,9 +611,17 @@ function quotaLeftPercentFromUsed(usedPercent: number | undefined): number | und function formatCompactQuotaSnapshot(snapshot: CodexQuotaSnapshot): string { const parts = [ - formatCompactQuotaPart(snapshot.primary.windowMinutes, snapshot.primary.usedPercent), - formatCompactQuotaPart(snapshot.secondary.windowMinutes, snapshot.secondary.usedPercent), - ].filter((value): value is string => typeof value === "string" && value.length > 0); + formatCompactQuotaPart( + snapshot.primary.windowMinutes, + snapshot.primary.usedPercent, + ), + formatCompactQuotaPart( + snapshot.secondary.windowMinutes, + snapshot.secondary.usedPercent, + ), + ].filter( + (value): value is string => typeof value === "string" && value.length > 0, + ); if (snapshot.status === 429) { parts.push("rate-limited"); } @@ -586,9 +633,17 @@ function formatCompactQuotaSnapshot(snapshot: CodexQuotaSnapshot): string { function formatAccountQuotaSummary(entry: QuotaCacheEntry): string { const parts = [ - formatCompactQuotaPart(entry.primary.windowMinutes, entry.primary.usedPercent), - formatCompactQuotaPart(entry.secondary.windowMinutes, entry.secondary.usedPercent), - ].filter((value): value is string => typeof value === "string" && value.length > 0); + formatCompactQuotaPart( + entry.primary.windowMinutes, + entry.primary.usedPercent, + ), + formatCompactQuotaPart( + entry.secondary.windowMinutes, + entry.secondary.usedPercent, + ), + ].filter( + (value): value is string => typeof value === "string" && value.length > 0, + ); if (entry.status === 429) { parts.push("rate-limited"); } @@ -865,11 +920,17 @@ function hasUsableAccessToken( now: number, ): boolean { if (!account.accessToken) return false; - if (typeof account.expiresAt !== "number" || !Number.isFinite(account.expiresAt)) return false; + if ( + typeof account.expiresAt !== "number" || + !Number.isFinite(account.expiresAt) + ) + return false; return account.expiresAt - now > ACCESS_TOKEN_FRESH_WINDOW_MS; } -function hasLikelyInvalidRefreshToken(refreshToken: string | undefined): boolean { +function hasLikelyInvalidRefreshToken( + refreshToken: string | undefined, +): boolean { if (!refreshToken) return true; const trimmed = refreshToken.trim(); if (trimmed.length < 20) return true; @@ -883,7 +944,10 @@ function mapAccountStatus( now: number, ): ExistingAccountInfo["status"] { if (account.enabled === false) return "disabled"; - if (typeof account.coolingDownUntil === "number" && account.coolingDownUntil > now) { + if ( + typeof account.coolingDownUntil === "number" && + account.coolingDownUntil > now + ) { return "cooldown"; } const rateLimit = formatRateLimitEntry(account, now, "codex"); @@ -897,7 +961,9 @@ function parseLeftPercentFromQuotaSummary( windowLabel: "5h" | "7d", ): number { if (!summary) return -1; - const match = summary.match(new RegExp(`(?:^|\\|)\\s*${windowLabel}\\s+(\\d{1,3})%`, "i")); + const match = summary.match( + new RegExp(`(?:^|\\|)\\s*${windowLabel}\\s+(\\d{1,3})%`, "i"), + ); const value = Number.parseInt(match?.[1] ?? "", 10); if (!Number.isFinite(value)) return -1; return Math.max(0, Math.min(100, value)); @@ -907,14 +973,19 @@ function readQuotaLeftPercent( account: ExistingAccountInfo, windowLabel: "5h" | "7d", ): number { - const direct = windowLabel === "5h" ? account.quota5hLeftPercent : account.quota7dLeftPercent; + const direct = + windowLabel === "5h" + ? account.quota5hLeftPercent + : account.quota7dLeftPercent; if (typeof direct === "number" && Number.isFinite(direct)) { return Math.max(0, Math.min(100, Math.round(direct))); } return parseLeftPercentFromQuotaSummary(account.quotaSummary, windowLabel); } -function accountStatusSortBucket(status: ExistingAccountInfo["status"]): number { +function accountStatusSortBucket( + status: ExistingAccountInfo["status"], +): number { switch (status) { case "active": case "ok": @@ -945,7 +1016,9 @@ function compareReadyFirstAccounts( const right7d = readQuotaLeftPercent(right, "7d"); if (left7d !== right7d) return right7d - left7d; - const bucketDelta = accountStatusSortBucket(left.status) - accountStatusSortBucket(right.status); + const bucketDelta = + accountStatusSortBucket(left.status) - + accountStatusSortBucket(right.status); if (bucketDelta !== 0) return bucketDelta; const leftLastUsed = left.lastUsed ?? 0; @@ -962,18 +1035,26 @@ function applyAccountMenuOrdering( displaySettings: DashboardDisplaySettings, ): ExistingAccountInfo[] { const sortEnabled = - displaySettings.menuSortEnabled ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? true); + displaySettings.menuSortEnabled ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? + true; const sortMode: DashboardAccountSortMode = - displaySettings.menuSortMode ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? "ready-first"); + displaySettings.menuSortMode ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? + "ready-first"; if (!sortEnabled || sortMode !== "ready-first") { return [...accounts]; } const sorted = [...accounts].sort(compareReadyFirstAccounts); - const pinCurrent = displaySettings.menuSortPinCurrent ?? - (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortPinCurrent ?? false); + const pinCurrent = + displaySettings.menuSortPinCurrent ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortPinCurrent ?? + false; if (pinCurrent) { - const currentIndex = sorted.findIndex((account) => account.isCurrentAccount); + const currentIndex = sorted.findIndex( + (account) => account.isCurrentAccount, + ); if (currentIndex > 0) { const current = sorted.splice(currentIndex, 1)[0]; const first = sorted[0]; @@ -1014,12 +1095,15 @@ function toExistingAccountInfo( addedAt: account.addedAt, lastUsed: account.lastUsed, status: mapAccountStatus(account, index, activeIndex, now), - quotaSummary: (displaySettings.menuShowQuotaSummary ?? true) && entry - ? formatAccountQuotaSummary(entry) - : undefined, + quotaSummary: + (displaySettings.menuShowQuotaSummary ?? true) && entry + ? formatAccountQuotaSummary(entry) + : undefined, quota5hLeftPercent: quotaLeftPercentFromUsed(entry?.primary.usedPercent), quota5hResetAtMs: entry?.primary.resetAtMs, - quota7dLeftPercent: quotaLeftPercentFromUsed(entry?.secondary.usedPercent), + quota7dLeftPercent: quotaLeftPercentFromUsed( + entry?.secondary.usedPercent, + ), quota7dResetAtMs: entry?.secondary.resetAtMs, quotaRateLimited: entry?.status === 429, isCurrentAccount: index === activeIndex, @@ -1031,11 +1115,19 @@ function toExistingAccountInfo( showHintsForUnselectedRows: layoutMode === "expanded-rows", highlightCurrentRow: displaySettings.menuHighlightCurrentRow ?? true, focusStyle: displaySettings.menuFocusStyle ?? "row-invert", - statuslineFields: displaySettings.menuStatuslineFields ?? ["last-used", "limits", "status"], + statuslineFields: displaySettings.menuStatuslineFields ?? [ + "last-used", + "limits", + "status", + ], }; }); - const orderedAccounts = applyAccountMenuOrdering(baseAccounts, displaySettings); - const quickSwitchUsesVisibleRows = displaySettings.menuSortQuickSwitchVisibleRow ?? true; + const orderedAccounts = applyAccountMenuOrdering( + baseAccounts, + displaySettings, + ); + const quickSwitchUsesVisibleRows = + displaySettings.menuSortQuickSwitchVisibleRow ?? true; return orderedAccounts.map((account, displayIndex) => ({ ...account, index: displayIndex, @@ -1045,7 +1137,9 @@ function toExistingAccountInfo( })); } -function resolveAccountSelection(tokens: TokenSuccess): TokenSuccessWithAccount { +function resolveAccountSelection( + tokens: TokenSuccess, +): TokenSuccessWithAccount { const override = (process.env.CODEX_AUTH_ACCOUNT_ID ?? "").trim(); if (override) { return { @@ -1108,7 +1202,8 @@ function resolveStoredAccountIdentity( return { accountId, - accountIdSource: accountId === tokenAccountId ? "token" : storedAccountIdSource, + accountIdSource: + accountId === tokenAccountId ? "token" : storedAccountIdSource, }; } @@ -1124,8 +1219,10 @@ function applyTokenAccountIdentity( if (!nextIdentity.accountId) { return false; } - if (nextIdentity.accountId === account.accountId - && nextIdentity.accountIdSource === account.accountIdSource) { + if ( + nextIdentity.accountId === account.accountId && + nextIdentity.accountIdSource === account.accountIdSource + ) { return false; } @@ -1228,7 +1325,10 @@ function isReadlineClosedError(error: unknown): boolean { typeof error === "object" && error !== null && "code" in error ? String((error as { code?: unknown }).code) : ""; - return errorCode === "ERR_USE_AFTER_CLOSE" || /readline was closed/i.test(error.message); + return ( + errorCode === "ERR_USE_AFTER_CLOSE" || + /readline was closed/i.test(error.message) + ); } type OAuthSignInMode = "browser" | "manual" | "restore-backup" | "cancel"; @@ -1254,24 +1354,32 @@ async function promptOAuthSignInMode( const ui = getUiRuntimeOptions(); const items: MenuItem[] = [ - { label: UI_COPY.oauth.signInHeading, value: "cancel" as const, kind: "heading" }, + { + label: UI_COPY.oauth.signInHeading, + value: "cancel" as const, + kind: "heading", + }, { label: UI_COPY.oauth.openBrowser, value: "browser", color: "green" }, { label: UI_COPY.oauth.manualMode, value: "manual", color: "yellow" }, ...(backupOption ? [ - { separator: true, label: "", value: "cancel" as const }, - { label: UI_COPY.oauth.restoreHeading, value: "cancel" as const, kind: "heading" as const }, - { - label: UI_COPY.oauth.restoreSavedBackup, - value: "restore-backup" as const, - hint: UI_COPY.oauth.loadLastBackupHint( - backupOption.fileName, - backupOption.accountCount, - formatBackupSavedAt(backupOption.mtimeMs), - ), - color: "cyan" as const, - }, - ] + { separator: true, label: "", value: "cancel" as const }, + { + label: UI_COPY.oauth.restoreHeading, + value: "cancel" as const, + kind: "heading" as const, + }, + { + label: UI_COPY.oauth.restoreSavedBackup, + value: "restore-backup" as const, + hint: UI_COPY.oauth.loadLastBackupHint( + backupOption.fileName, + backupOption.accountCount, + formatBackupSavedAt(backupOption.mtimeMs), + ), + color: "cyan" as const, + }, + ] : []), { separator: true, label: "", value: "cancel" as const }, { label: UI_COPY.oauth.back, value: "cancel", color: "red" }, @@ -1356,15 +1464,17 @@ async function promptManualBackupSelection( } const ui = getUiRuntimeOptions(); - const items: MenuItem[] = backups.map((backup) => ({ - label: backup.fileName, - value: backup, - hint: UI_COPY.oauth.manualBackupHint( - backup.accountCount, - formatBackupSavedAt(backup.mtimeMs), - ), - color: "cyan", - })); + const items: MenuItem[] = backups.map( + (backup) => ({ + label: backup.fileName, + value: backup, + hint: UI_COPY.oauth.manualBackupHint( + backup.accountCount, + formatBackupSavedAt(backup.mtimeMs), + ), + color: "cyan", + }), + ); items.push({ label: UI_COPY.oauth.back, value: null, color: "red" }); const selected = await select(items, { @@ -1390,7 +1500,9 @@ interface WaitForReturnOptions { pauseOnAnyKey?: boolean; } -async function waitForMenuReturn(options: WaitForReturnOptions = {}): Promise { +async function waitForMenuReturn( + options: WaitForReturnOptions = {}, +): Promise { if (!input.isTTY || !output.isTTY) { return; } @@ -1429,9 +1541,7 @@ async function waitForMenuReturn(options: WaitForReturnOptions = {}): Promise((resolve) => { @@ -1506,7 +1616,8 @@ async function waitForMenuReturn(options: WaitForReturnOptions = {}): Promise 0 ? `${stylePromptText(promptText, "muted")} ` : ""; + const question = + promptText.length > 0 ? `${stylePromptText(promptText, "muted")} ` : ""; output.write(`\r${ANSI.clearLine}`); await rl.question(question); } catch (error) { @@ -1575,7 +1686,12 @@ async function runActionPanel( ? UI_COPY.returnFlow.failed : UI_COPY.returnFlow.done; previousLog(stylePromptText(title, "accent")); - previousLog(stylePromptText(stageText, failed ? "danger" : running ? "accent" : "success")); + previousLog( + stylePromptText( + stageText, + failed ? "danger" : running ? "accent" : "success", + ), + ); previousLog(""); const lines = captured.slice(-maxVisibleLines); @@ -1588,7 +1704,8 @@ async function runActionPanel( previousLog(""); } previousLog(""); - if (running) previousLog(stylePromptText(UI_COPY.returnFlow.working, "muted")); + if (running) + previousLog(stylePromptText(UI_COPY.returnFlow.working, "muted")); frame += 1; }; @@ -1637,7 +1754,9 @@ async function runActionPanel( pauseOnAnyKey: settings?.actionPauseOnKey ?? true, }); } - output.write(ANSI.altScreenOff + ANSI.show + ANSI.clearScreen + ANSI.moveTo(1, 1)); + output.write( + ANSI.altScreenOff + ANSI.show + ANSI.clearScreen + ANSI.moveTo(1, 1), + ); if (failed) { throw failed; } @@ -1649,7 +1768,8 @@ async function runOAuthFlow( ): Promise { const { pkce, state, url } = await createAuthorizationFlow({ forceNewLogin }); let code: string | null = null; - let oauthServer: Awaited> | null = null; + let oauthServer: Awaited> | null = + null; try { if (signInMode === "browser") { try { @@ -1659,15 +1779,15 @@ async function runOAuthFlow( "Local OAuth callback server unavailable; falling back to manual callback entry.", serverError instanceof Error ? { - message: serverError.message, - stack: serverError.stack, - code: - typeof serverError === "object" && - serverError !== null && - "code" in serverError - ? String(serverError.code) - : undefined, - } + message: serverError.message, + stack: serverError.stack, + code: + typeof serverError === "object" && + serverError !== null && + "code" in serverError + ? String(serverError.code) + : undefined, + } : { error: String(serverError) }, ); oauthServer = null; @@ -1700,7 +1820,8 @@ async function runOAuthFlow( ); } - const waitingForCallback = signInMode === "browser" && oauthServer?.ready === true; + const waitingForCallback = + signInMode === "browser" && oauthServer?.ready === true; if (waitingForCallback && oauthServer) { console.log(stylePromptText(UI_COPY.oauth.waitingCallback, "muted")); const callbackResult = await oauthServer.waitForCode(state); @@ -1718,7 +1839,9 @@ async function runOAuthFlow( "warning", ), ); - code = await promptManualCallback(state, { allowNonTty: signInMode === "manual" }); + code = await promptManualCallback(state, { + allowNonTty: signInMode === "manual", + }); } } finally { oauthServer?.close(); @@ -1754,19 +1877,24 @@ async function persistAccountPool( tokenAccountId, ); const accountIdSource = accountId - ? (result.accountIdSource ?? (result.accountIdOverride ? "manual" : "token")) + ? (result.accountIdSource ?? + (result.accountIdOverride ? "manual" : "token")) : undefined; const accountLabel = result.accountLabel; const accountEmail = sanitizeEmail( extractAccountEmail(result.access, result.idToken), ); - const existingIndex = findMatchingAccountIndex(accounts, { - accountId, - email: accountEmail, - refreshToken: result.refresh, - }, { - allowUniqueAccountIdFallbackWithoutEmail: true, - }); + const existingIndex = findMatchingAccountIndex( + accounts, + { + accountId, + email: accountEmail, + refreshToken: result.refresh, + }, + { + allowUniqueAccountIdFallbackWithoutEmail: true, + }, + ); if (existingIndex === undefined) { const newIndex = accounts.length; @@ -1810,17 +1938,16 @@ async function persistAccountPool( selectedAccountIndex = existingIndex; } - const fallbackActiveIndex = accounts.length === 0 - ? 0 - : Math.max( - 0, - Math.min(stored?.activeIndex ?? 0, accounts.length - 1), - ); - const nextActiveIndex = accounts.length === 0 - ? 0 - : selectedAccountIndex === null - ? fallbackActiveIndex - : Math.max(0, Math.min(selectedAccountIndex, accounts.length - 1)); + const fallbackActiveIndex = + accounts.length === 0 + ? 0 + : Math.max(0, Math.min(stored?.activeIndex ?? 0, accounts.length - 1)); + const nextActiveIndex = + accounts.length === 0 + ? 0 + : selectedAccountIndex === null + ? fallbackActiveIndex + : Math.max(0, Math.min(selectedAccountIndex, accounts.length - 1)); const activeIndexByFamily: Partial> = {}; for (const family of MODEL_FAMILIES) { activeIndexByFamily[family] = nextActiveIndex; @@ -1835,14 +1962,18 @@ async function persistAccountPool( }); } -async function syncSelectionToCodex(tokens: TokenSuccessWithAccount): Promise { +async function syncSelectionToCodex( + tokens: TokenSuccessWithAccount, +): Promise { const tokenAccountId = extractAccountId(tokens.access); const accountId = resolveRequestAccountId( tokens.accountIdOverride, tokens.accountIdSource, tokenAccountId, ); - const email = sanitizeEmail(extractAccountEmail(tokens.access, tokens.idToken)); + const email = sanitizeEmail( + extractAccountEmail(tokens.access, tokens.idToken), + ); await setCodexCliActiveSelection({ accountId, email, @@ -1880,9 +2011,10 @@ async function showAccountStatus(): Promise { const cooldown = formatCooldown(account, now); if (cooldown) markers.push(`cooldown:${cooldown}`); const markerLabel = markers.length > 0 ? ` [${markers.join(", ")}]` : ""; - const lastUsed = typeof account.lastUsed === "number" && account.lastUsed > 0 - ? `used ${formatWaitTime(now - account.lastUsed)} ago` - : "never used"; + const lastUsed = + typeof account.lastUsed === "number" && account.lastUsed > 0 + ? `used ${formatWaitTime(now - account.lastUsed)} ago` + : "never used"; console.log(`${i + 1}. ${label}${markerLabel} ${lastUsed}`); } } @@ -1920,12 +2052,14 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { const activeIndex = resolveActiveIndex(storage, "codex"); let activeAccountRefreshed = false; const now = Date.now(); - console.log(stylePromptText( - forceRefresh - ? `Checking ${storage.accounts.length} account(s) with full refresh test...` - : `Checking ${storage.accounts.length} account(s) with quick check${liveProbe ? " + live check" : ""}...`, - "accent", - )); + console.log( + stylePromptText( + forceRefresh + ? `Checking ${storage.accounts.length} account(s) with full refresh test...` + : `Checking ${storage.accounts.length} account(s) with quick check${liveProbe ? " + live check" : ""}...`, + "accent", + ), + ); for (let i = 0; i < storage.accounts.length; i += 1) { const account = storage.accounts[i]; if (!account) continue; @@ -1948,7 +2082,8 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { : undefined; if (!probeAccountId || !currentAccessToken) { warnings += 1; - healthDetail = "signed in and working (live check skipped: missing account ID)"; + healthDetail = + "signed in and working (live check skipped: missing account ID)"; } else { try { const snapshot = await fetchCodexQuotaSnapshot({ @@ -1991,7 +2126,9 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { const result = await queuedRefresh(account.refreshToken); if (result.type === "success") { const tokenAccountId = extractAccountId(result.access); - const nextEmail = sanitizeEmail(extractAccountEmail(result.access, result.idToken)); + const nextEmail = sanitizeEmail( + extractAccountEmail(result.access, result.idToken), + ); const previousEmail = account.email; let accountIdentityChanged = false; if (account.refreshToken !== result.refresh) { @@ -2020,7 +2157,9 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { changed = true; } if (accountIdentityChanged && liveProbe && workingQuotaCache) { - quotaEmailFallbackState = buildQuotaEmailFallbackState(storage.accounts); + quotaEmailFallbackState = buildQuotaEmailFallbackState( + storage.accounts, + ); quotaCacheChanged = pruneUnsafeQuotaEmailCacheEntry( workingQuotaCache, @@ -2039,7 +2178,8 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { const probeAccountId = account.accountId ?? tokenAccountId; if (!probeAccountId) { warnings += 1; - healthyMessage = "working now (live check skipped: missing account ID)"; + healthyMessage = + "working now (live check skipped: missing account ID)"; } else { try { const snapshot = await fetchCodexQuotaSnapshot({ @@ -2094,7 +2234,12 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { } if (!display.showPerAccountRows) { - console.log(stylePromptText("Per-account lines are hidden in dashboard settings.", "muted")); + console.log( + stylePromptText( + "Per-account lines are hidden in dashboard settings.", + "muted", + ), + ); } if (workingQuotaCache && quotaCacheChanged) { await saveQuotaCache(workingQuotaCache); @@ -2104,7 +2249,11 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { await saveAccounts(storage); } - if (activeAccountRefreshed && activeIndex >= 0 && activeIndex < storage.accounts.length) { + if ( + activeAccountRefreshed && + activeIndex >= 0 && + activeIndex < storage.accounts.length + ) { const activeAccount = storage.accounts[activeIndex]; if (activeAccount) { await setCodexCliActiveSelection({ @@ -2118,16 +2267,25 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { } console.log(""); - console.log(formatResultSummary([ - { text: `${ok} working`, tone: "success" }, - { text: `${failed} need re-login`, tone: failed > 0 ? "danger" : "muted" }, - { text: `${warnings} warning${warnings === 1 ? "" : "s"}`, tone: warnings > 0 ? "warning" : "muted" }, - ])); + console.log( + formatResultSummary([ + { text: `${ok} working`, tone: "success" }, + { + text: `${failed} need re-login`, + tone: failed > 0 ? "danger" : "muted", + }, + { + text: `${warnings} warning${warnings === 1 ? "" : "s"}`, + tone: warnings > 0 ? "warning" : "muted", + }, + ]), + ); } interface ForecastCliOptions { live: boolean; json: boolean; + explain: boolean; model: string; } @@ -2158,17 +2316,20 @@ interface VerifyFlaggedCliOptions { restore: boolean; } -type ParsedArgsResult = { ok: true; options: T } | { ok: false; message: string }; +type ParsedArgsResult = + | { ok: true; options: T } + | { ok: false; message: string }; function printForecastUsage(): void { console.log( [ "Usage:", - " codex auth forecast [--live] [--json] [--model ]", + " codex auth forecast [--live] [--json] [--explain] [--model ]", "", "Options:", " --live, -l Probe live quota headers via Codex backend", " --json, -j Print machine-readable JSON output", + " --explain Include structured recommendation reasoning", " --model, -m Probe model for live mode (default: gpt-5-codex)", ].join("\n"), ); @@ -2230,10 +2391,13 @@ function printVerifyFlaggedUsage(): void { ); } -function parseForecastArgs(args: string[]): ParsedArgsResult { +function parseForecastArgs( + args: string[], +): ParsedArgsResult { const options: ForecastCliOptions = { live: false, json: false, + explain: false, model: "gpt-5-codex", }; @@ -2249,6 +2413,10 @@ function parseForecastArgs(args: string[]): ParsedArgsResult options.json = true; continue; } + if (arg === "--explain") { + options.explain = true; + continue; + } if (arg === "--model" || arg === "-m") { const value = args[i + 1]; if (!value) { @@ -2362,7 +2530,9 @@ function parseFixArgs(args: string[]): ParsedArgsResult { return { ok: true, options }; } -function parseVerifyFlaggedArgs(args: string[]): ParsedArgsResult { +function parseVerifyFlaggedArgs( + args: string[], +): ParsedArgsResult { const options: VerifyFlaggedCliOptions = { dryRun: false, json: false, @@ -2511,7 +2681,10 @@ function parseReportArgs(args: string[]): ParsedArgsResult { function serializeForecastResults( results: ForecastAccountResult[], - liveQuotaByIndex: Map>>, + liveQuotaByIndex: Map< + number, + Awaited> + >, refreshFailures: Map, ): Array<{ index: number; @@ -2544,12 +2717,12 @@ function serializeForecastResults( reasons: result.reasons, liveQuota: liveQuota ? { - status: liveQuota.status, - planType: liveQuota.planType, - activeLimit: liveQuota.activeLimit, - model: liveQuota.model, - summary: formatQuotaSnapshotLine(liveQuota), - } + status: liveQuota.status, + planType: liveQuota.planType, + activeLimit: liveQuota.activeLimit, + model: liveQuota.model, + summary: formatQuotaSnapshotLine(liveQuota), + } : undefined, refreshFailure: refreshFailures.get(result.index), }; @@ -2588,7 +2761,10 @@ async function runForecast(args: string[]): Promise { const now = Date.now(); const activeIndex = resolveActiveIndex(storage, "codex"); const refreshFailures = new Map(); - const liveQuotaByIndex = new Map>>(); + const liveQuotaByIndex = new Map< + number, + Awaited> + >(); const probeErrors: string[] = []; for (let i = 0; i < storage.accounts.length; i += 1) { @@ -2597,22 +2773,29 @@ async function runForecast(args: string[]): Promise { if (account.enabled === false) continue; let probeAccessToken = account.accessToken; - let probeAccountId = account.accountId ?? extractAccountId(account.accessToken); + let probeAccountId = + account.accountId ?? extractAccountId(account.accessToken); if (!hasUsableAccessToken(account, now)) { const refreshResult = await queuedRefresh(account.refreshToken); if (refreshResult.type !== "success") { refreshFailures.set(i, { ...refreshResult, - message: normalizeFailureDetail(refreshResult.message, refreshResult.reason), + message: normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ), }); continue; } probeAccessToken = refreshResult.access; - probeAccountId = account.accountId ?? extractAccountId(refreshResult.access); + probeAccountId = + account.accountId ?? extractAccountId(refreshResult.access); } if (!probeAccessToken || !probeAccountId) { - probeErrors.push(`${formatAccountLabel(account, i)}: missing accountId for live probe`); + probeErrors.push( + `${formatAccountLabel(account, i)}: missing accountId for live probe`, + ); continue; } @@ -2656,6 +2839,7 @@ async function runForecast(args: string[]): Promise { const forecastResults = evaluateForecastAccounts(forecastInputs); const summary = summarizeForecast(forecastResults); const recommendation = recommendForecastAccount(forecastResults); + const explanation = buildForecastExplanation(forecastResults, recommendation); if (options.json) { if (workingQuotaCache && quotaCacheChanged) { @@ -2669,8 +2853,13 @@ async function runForecast(args: string[]): Promise { liveProbe: options.live, summary, recommendation, + explanation: options.explain ? explanation : undefined, probeErrors, - accounts: serializeForecastResults(forecastResults, liveQuotaByIndex, refreshFailures), + accounts: serializeForecastResults( + forecastResults, + liveQuotaByIndex, + refreshFailures, + ), }, null, 2, @@ -2689,8 +2878,14 @@ async function runForecast(args: string[]): Promise { formatResultSummary([ { text: `${summary.ready} ready now`, tone: "success" }, { text: `${summary.delayed} waiting`, tone: "warning" }, - { text: `${summary.unavailable} unavailable`, tone: summary.unavailable > 0 ? "danger" : "muted" }, - { text: `${summary.highRisk} high risk`, tone: summary.highRisk > 0 ? "danger" : "muted" }, + { + text: `${summary.unavailable} unavailable`, + tone: summary.unavailable > 0 ? "danger" : "muted", + }, + { + text: `${summary.highRisk} high risk`, + tone: summary.highRisk > 0 ? "danger" : "muted", + }, ]), ); console.log(""); @@ -2700,25 +2895,48 @@ async function runForecast(args: string[]): Promise { continue; } const currentTag = result.isCurrent ? " [current]" : ""; - const waitLabel = result.waitMs > 0 ? stylePromptText(`wait ${formatWaitTime(result.waitMs)}`, "muted") : ""; + const waitLabel = + result.waitMs > 0 + ? stylePromptText(`wait ${formatWaitTime(result.waitMs)}`, "muted") + : ""; const indexLabel = stylePromptText(`${result.index + 1}.`, "accent"); - const accountLabel = stylePromptText(`${result.label}${currentTag}`, "accent"); - const riskLabel = stylePromptText(`${result.riskLevel} risk (${result.riskScore})`, riskTone(result.riskLevel)); - const availabilityLabel = stylePromptText(result.availability, availabilityTone(result.availability)); + const accountLabel = stylePromptText( + `${result.label}${currentTag}`, + "accent", + ); + const riskLabel = stylePromptText( + `${result.riskLevel} risk (${result.riskScore})`, + riskTone(result.riskLevel), + ); + const availabilityLabel = stylePromptText( + result.availability, + availabilityTone(result.availability), + ); const rowParts = [availabilityLabel, riskLabel]; if (waitLabel) rowParts.push(waitLabel); - console.log(`${indexLabel} ${accountLabel} ${stylePromptText("|", "muted")} ${joinStyledSegments(rowParts)}`); + console.log( + `${indexLabel} ${accountLabel} ${stylePromptText("|", "muted")} ${joinStyledSegments(rowParts)}`, + ); if (display.showForecastReasons && result.reasons.length > 0) { - console.log(` ${stylePromptText(result.reasons.slice(0, 3).join("; "), "muted")}`); + console.log( + ` ${stylePromptText(result.reasons.slice(0, 3).join("; "), "muted")}`, + ); } const liveQuota = liveQuotaByIndex.get(result.index); if (display.showQuotaDetails && liveQuota) { - console.log(` ${stylePromptText("quota:", "accent")} ${styleQuotaSummary(formatCompactQuotaSnapshot(liveQuota))}`); + console.log( + ` ${stylePromptText("quota:", "accent")} ${styleQuotaSummary(formatCompactQuotaSnapshot(liveQuota))}`, + ); } } if (!display.showPerAccountRows) { - console.log(stylePromptText("Per-account lines are hidden in dashboard settings.", "muted")); + console.log( + stylePromptText( + "Per-account lines are hidden in dashboard settings.", + "muted", + ), + ); } if (display.showRecommendations) { @@ -2730,21 +2948,42 @@ async function runForecast(args: string[]): Promise { console.log( `${stylePromptText("Best next account:", "accent")} ${stylePromptText(`${index + 1} (${account.label})`, "success")}`, ); - console.log(`${stylePromptText("Why:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`); + console.log( + `${stylePromptText("Why:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`, + ); if (index !== activeIndex) { - console.log(`${stylePromptText("Switch now with:", "accent")} codex auth switch ${index + 1}`); + console.log( + `${stylePromptText("Switch now with:", "accent")} codex auth switch ${index + 1}`, + ); } } } else { - console.log(`${stylePromptText("Note:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`); + console.log( + `${stylePromptText("Note:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`, + ); + } + if (options.explain) { + console.log(""); + console.log(stylePromptText("Explain:", "accent")); + for (const item of explanation.considered) { + const prefix = item.selected ? "*" : "-"; + const reasons = item.reasons.slice(0, 3).join("; "); + console.log( + `${stylePromptText(prefix, item.selected ? "success" : "muted")} ${stylePromptText(`${item.index + 1}. ${item.label}`, item.selected ? "success" : "accent")} ${stylePromptText("|", "muted")} ${stylePromptText(`${item.availability}, ${item.riskLevel} risk (${item.riskScore})`, item.selected ? "success" : "muted")}${reasons ? ` ${stylePromptText("|", "muted")} ${stylePromptText(reasons, "muted")}` : ""}`, + ); + } } } if (display.showLiveProbeNotes && probeErrors.length > 0) { console.log(""); - console.log(stylePromptText(`Live check notes (${probeErrors.length}):`, "warning")); + console.log( + stylePromptText(`Live check notes (${probeErrors.length}):`, "warning"), + ); for (const error of probeErrors) { - console.log(` ${stylePromptText("-", "warning")} ${stylePromptText(error, "muted")}`); + console.log( + ` ${stylePromptText("-", "warning")} ${stylePromptText(error, "muted")}`, + ); } } if (workingQuotaCache && quotaCacheChanged) { @@ -2775,7 +3014,10 @@ async function runReport(args: string[]): Promise { const accountCount = storage?.accounts.length ?? 0; const activeIndex = storage ? resolveActiveIndex(storage, "codex") : 0; const refreshFailures = new Map(); - const liveQuotaByIndex = new Map>>(); + const liveQuotaByIndex = new Map< + number, + Awaited> + >(); const probeErrors: string[] = []; if (storage && options.live) { @@ -2787,14 +3029,20 @@ async function runReport(args: string[]): Promise { if (refreshResult.type !== "success") { refreshFailures.set(i, { ...refreshResult, - message: normalizeFailureDetail(refreshResult.message, refreshResult.reason), + message: normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ), }); continue; } - const accountId = account.accountId ?? extractAccountId(refreshResult.access); + const accountId = + account.accountId ?? extractAccountId(refreshResult.access); if (!accountId) { - probeErrors.push(`${formatAccountLabel(account, i)}: missing accountId for live probe`); + probeErrors.push( + `${formatAccountLabel(account, i)}: missing accountId for live probe`, + ); continue; } @@ -2836,11 +3084,14 @@ async function runReport(args: string[]): Promise { const coolingCount = storage ? storage.accounts.filter( (account) => - typeof account.coolingDownUntil === "number" && account.coolingDownUntil > now, + typeof account.coolingDownUntil === "number" && + account.coolingDownUntil > now, ).length : 0; const rateLimitedCount = storage - ? storage.accounts.filter((account) => !!formatRateLimitEntry(account, now, "codex")).length + ? storage.accounts.filter( + (account) => !!formatRateLimitEntry(account, now, "codex"), + ).length : 0; const report = { @@ -2861,14 +3112,22 @@ async function runReport(args: string[]): Promise { summary: forecastSummary, recommendation, probeErrors, - accounts: serializeForecastResults(forecastResults, liveQuotaByIndex, refreshFailures), + accounts: serializeForecastResults( + forecastResults, + liveQuotaByIndex, + refreshFailures, + ), }, }; if (options.outPath) { const outputPath = resolve(process.cwd(), options.outPath); await fs.mkdir(dirname(outputPath), { recursive: true }); - await fs.writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, "utf-8"); + await fs.writeFile( + outputPath, + `${JSON.stringify(report, null, 2)}\n`, + "utf-8", + ); } if (options.json) { @@ -2916,9 +3175,7 @@ interface FixAccountReport { message: string; } -function summarizeFixReports( - reports: FixAccountReport[], -): { +function summarizeFixReports(reports: FixAccountReport[]): { healthy: number; disabled: number; warnings: number; @@ -2967,24 +3224,32 @@ function findExistingAccountIndexForFlagged( const flaggedEmail = sanitizeEmail(flagged.email); const candidateAccountId = nextAccountId ?? flagged.accountId; const candidateEmail = sanitizeEmail(nextEmail) ?? flaggedEmail; - const nextMatchIndex = findMatchingAccountIndex(storage.accounts, { - accountId: candidateAccountId, - email: candidateEmail, - refreshToken: nextRefreshToken, - }, { - allowUniqueAccountIdFallbackWithoutEmail: true, - }); + const nextMatchIndex = findMatchingAccountIndex( + storage.accounts, + { + accountId: candidateAccountId, + email: candidateEmail, + refreshToken: nextRefreshToken, + }, + { + allowUniqueAccountIdFallbackWithoutEmail: true, + }, + ); if (nextMatchIndex !== undefined) { return nextMatchIndex; } - const flaggedMatchIndex = findMatchingAccountIndex(storage.accounts, { - accountId: candidateAccountId, - email: candidateEmail, - refreshToken: flagged.refreshToken, - }, { - allowUniqueAccountIdFallbackWithoutEmail: true, - }); + const flaggedMatchIndex = findMatchingAccountIndex( + storage.accounts, + { + accountId: candidateAccountId, + email: candidateEmail, + refreshToken: flagged.refreshToken, + }, + { + allowUniqueAccountIdFallbackWithoutEmail: true, + }, + ); return flaggedMatchIndex ?? -1; } @@ -2994,10 +3259,17 @@ function upsertRecoveredFlaggedAccount( refreshResult: TokenSuccess, now: number, ): { restored: boolean; changed: boolean; message: string } { - const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)) ?? flagged.email; + const nextEmail = + sanitizeEmail( + extractAccountEmail(refreshResult.access, refreshResult.idToken), + ) ?? flagged.email; const tokenAccountId = extractAccountId(refreshResult.access); const { accountId: nextAccountId, accountIdSource: nextAccountIdSource } = - resolveStoredAccountIdentity(flagged.accountId, flagged.accountIdSource, tokenAccountId); + resolveStoredAccountIdentity( + flagged.accountId, + flagged.accountIdSource, + tokenAccountId, + ); const existingIndex = findExistingAccountIndexForFlagged( storage, flagged, @@ -3009,7 +3281,11 @@ function upsertRecoveredFlaggedAccount( if (existingIndex >= 0) { const existing = storage.accounts[existingIndex]; if (!existing) { - return { restored: false, changed: false, message: "existing account entry is missing" }; + return { + restored: false, + changed: false, + message: "existing account entry is missing", + }; } let changed = false; if (existing.refreshToken !== refreshResult.refresh) { @@ -3030,10 +3306,8 @@ function upsertRecoveredFlaggedAccount( } if ( nextAccountId !== undefined && - ( - (nextAccountId !== existing.accountId) - || (nextAccountIdSource !== existing.accountIdSource) - ) + (nextAccountId !== existing.accountId || + nextAccountIdSource !== existing.accountIdSource) ) { existing.accountId = nextAccountId; existing.accountIdSource = nextAccountIdSource; @@ -3043,7 +3317,10 @@ function upsertRecoveredFlaggedAccount( existing.enabled = true; changed = true; } - if (existing.accountLabel !== flagged.accountLabel && flagged.accountLabel) { + if ( + existing.accountLabel !== flagged.accountLabel && + flagged.accountLabel + ) { existing.accountLabel = flagged.accountLabel; changed = true; } @@ -3147,9 +3424,7 @@ async function runVerifyFlagged(args: string[]): Promise { }); } - const applyRefreshChecks = ( - storage: AccountStorageV3, - ): void => { + const applyRefreshChecks = (storage: AccountStorageV3): void => { for (const check of refreshChecks) { const { index: i, flagged, label, result } = check; if (result.type === "success") { @@ -3167,7 +3442,10 @@ async function runVerifyFlagged(args: string[]): Promise { expiresAt: result.expires, accountId: nextIdentity.accountId, accountIdSource: nextIdentity.accountIdSource, - email: sanitizeEmail(extractAccountEmail(result.access, result.idToken)) ?? flagged.email, + email: + sanitizeEmail( + extractAccountEmail(result.access, result.idToken), + ) ?? flagged.email, lastUsed: now, lastError: undefined, }; @@ -3179,12 +3457,18 @@ async function runVerifyFlagged(args: string[]): Promise { index: i, label, outcome: "healthy-flagged", - message: "session is healthy (left in flagged list due to --no-restore)", + message: + "session is healthy (left in flagged list due to --no-restore)", }); continue; } - const upsertResult = upsertRecoveredFlaggedAccount(storage, flagged, result, now); + const upsertResult = upsertRecoveredFlaggedAccount( + storage, + flagged, + result, + now, + ); if (upsertResult.restored) { storageChanged = storageChanged || upsertResult.changed; flaggedChanged = true; @@ -3210,7 +3494,9 @@ async function runVerifyFlagged(args: string[]): Promise { expiresAt: result.expires, accountId: nextIdentity.accountId, accountIdSource: nextIdentity.accountIdSource, - email: sanitizeEmail(extractAccountEmail(result.access, result.idToken)) ?? flagged.email, + email: + sanitizeEmail(extractAccountEmail(result.access, result.idToken)) ?? + flagged.email, lastUsed: now, lastError: upsertResult.message, }; @@ -3247,9 +3533,7 @@ async function runVerifyFlagged(args: string[]): Promise { if (options.restore) { if (options.dryRun) { - applyRefreshChecks( - (await loadAccounts()) ?? createEmptyAccountStorage(), - ); + applyRefreshChecks((await loadAccounts()) ?? createEmptyAccountStorage()); } else { await withAccountAndFlaggedStorageTransaction( async (loadedStorage, persist) => { @@ -3273,12 +3557,22 @@ async function runVerifyFlagged(args: string[]): Promise { } const remainingFlagged = nextFlaggedAccounts.length; - const restored = reports.filter((report) => report.outcome === "restored").length; - const healthyFlagged = reports.filter((report) => report.outcome === "healthy-flagged").length; - const stillFlagged = reports.filter((report) => report.outcome === "still-flagged").length; + const restored = reports.filter( + (report) => report.outcome === "restored", + ).length; + const healthyFlagged = reports.filter( + (report) => report.outcome === "healthy-flagged", + ).length; + const stillFlagged = reports.filter( + (report) => report.outcome === "still-flagged", + ).length; const changed = storageChanged || flaggedChanged; - if (!options.dryRun && flaggedChanged && (!options.restore || !storageChanged)) { + if ( + !options.dryRun && + flaggedChanged && + (!options.restore || !storageChanged) + ) { await saveFlaggedAccounts({ version: 1, accounts: nextFlaggedAccounts, @@ -3314,32 +3608,47 @@ async function runVerifyFlagged(args: string[]): Promise { ), ); for (const report of reports) { - const tone = report.outcome === "restored" - ? "success" - : report.outcome === "healthy-flagged" - ? "warning" - : report.outcome === "restore-skipped" + const tone = + report.outcome === "restored" + ? "success" + : report.outcome === "healthy-flagged" ? "warning" - : "danger"; - const marker = report.outcome === "restored" - ? "✓" - : report.outcome === "healthy-flagged" - ? "!" - : report.outcome === "restore-skipped" + : report.outcome === "restore-skipped" + ? "warning" + : "danger"; + const marker = + report.outcome === "restored" + ? "✓" + : report.outcome === "healthy-flagged" ? "!" - : "✗"; + : report.outcome === "restore-skipped" + ? "!" + : "✗"; console.log( `${stylePromptText(marker, tone)} ${stylePromptText(`${report.index + 1}. ${report.label}`, "accent")} ${stylePromptText("|", "muted")} ${styleAccountDetailText(report.message, tone)}`, ); } console.log(""); - console.log(formatResultSummary([ - { text: `${restored} restored`, tone: restored > 0 ? "success" : "muted" }, - { text: `${healthyFlagged} healthy (kept flagged)`, tone: healthyFlagged > 0 ? "warning" : "muted" }, - { text: `${stillFlagged} still flagged`, tone: stillFlagged > 0 ? "danger" : "muted" }, - ])); + console.log( + formatResultSummary([ + { + text: `${restored} restored`, + tone: restored > 0 ? "success" : "muted", + }, + { + text: `${healthyFlagged} healthy (kept flagged)`, + tone: healthyFlagged > 0 ? "warning" : "muted", + }, + { + text: `${stillFlagged} still flagged`, + tone: stillFlagged > 0 ? "danger" : "muted", + }, + ]), + ); if (options.dryRun) { - console.log(stylePromptText("Preview only: no changes were saved.", "warning")); + console.log( + stylePromptText("Preview only: no changes were saved.", "warning"), + ); } else if (!changed) { console.log(stylePromptText("No storage changes were needed.", "muted")); } @@ -3459,7 +3768,9 @@ async function runFix(args: string[]): Promise { const refreshResult = await queuedRefresh(account.refreshToken); if (refreshResult.type === "success") { - const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)); + const nextEmail = sanitizeEmail( + extractAccountEmail(refreshResult.access, refreshResult.idToken), + ); const nextAccountId = extractAccountId(refreshResult.access); const previousEmail = account.email; let accountChanged = false; @@ -3489,7 +3800,9 @@ async function runFix(args: string[]): Promise { if (accountChanged) changed = true; if (accountIdentityChanged && options.live && workingQuotaCache) { - quotaEmailFallbackState = buildQuotaEmailFallbackState(storage.accounts); + quotaEmailFallbackState = buildQuotaEmailFallbackState( + storage.accounts, + ); quotaCacheChanged = pruneUnsafeQuotaEmailCacheEntry( workingQuotaCache, @@ -3550,7 +3863,10 @@ async function runFix(args: string[]): Promise { continue; } - const detail = normalizeFailureDetail(refreshResult.message, refreshResult.reason); + const detail = normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ); refreshFailures.set(i, { ...refreshResult, message: detail, @@ -3576,13 +3892,17 @@ async function runFix(args: string[]): Promise { } if (hardDisabledIndexes.length > 0) { - const enabledCount = storage.accounts.filter((account) => account.enabled !== false).length; + const enabledCount = storage.accounts.filter( + (account) => account.enabled !== false, + ).length; if (enabledCount === 0) { - const fallbackIndex = - hardDisabledIndexes.includes(activeIndex) ? activeIndex : hardDisabledIndexes[0]; - const fallback = typeof fallbackIndex === "number" - ? storage.accounts[fallbackIndex] - : undefined; + const fallbackIndex = hardDisabledIndexes.includes(activeIndex) + ? activeIndex + : hardDisabledIndexes[0]; + const fallback = + typeof fallbackIndex === "number" + ? storage.accounts[fallbackIndex] + : undefined; if (fallback && fallback.enabled === false) { fallback.enabled = true; changed = true; @@ -3631,7 +3951,7 @@ async function runFix(args: string[]): Promise { recommendation, recommendedSwitchCommand: recommendation.recommendedIndex !== null && - recommendation.recommendedIndex !== activeIndex + recommendation.recommendedIndex !== activeIndex ? `codex auth switch ${recommendation.recommendedIndex + 1}` : null, reports, @@ -3643,16 +3963,26 @@ async function runFix(args: string[]): Promise { return 0; } - console.log(stylePromptText(`Auto-fix scan (${options.dryRun ? "preview" : "apply"})`, "accent")); - console.log(formatResultSummary([ - { text: `${reportSummary.healthy} working`, tone: "success" }, - { text: `${reportSummary.disabled} disabled`, tone: reportSummary.disabled > 0 ? "danger" : "muted" }, - { - text: `${reportSummary.warnings} warning${reportSummary.warnings === 1 ? "" : "s"}`, - tone: reportSummary.warnings > 0 ? "warning" : "muted", - }, - { text: `${reportSummary.skipped} already disabled`, tone: "muted" }, - ])); + console.log( + stylePromptText( + `Auto-fix scan (${options.dryRun ? "preview" : "apply"})`, + "accent", + ), + ); + console.log( + formatResultSummary([ + { text: `${reportSummary.healthy} working`, tone: "success" }, + { + text: `${reportSummary.disabled} disabled`, + tone: reportSummary.disabled > 0 ? "danger" : "muted", + }, + { + text: `${reportSummary.warnings} warning${reportSummary.warnings === 1 ? "" : "s"}`, + tone: reportSummary.warnings > 0 ? "warning" : "muted", + }, + { text: `${reportSummary.skipped} already disabled`, tone: "muted" }, + ]), + ); if (display.showPerAccountRows) { console.log(""); for (const report of reports) { @@ -3664,33 +3994,47 @@ async function runFix(args: string[]): Promise { : report.outcome === "warning-soft-failure" ? "!" : "-"; - const tone = report.outcome === "healthy" - ? "success" - : report.outcome === "disabled-hard-failure" - ? "danger" - : report.outcome === "warning-soft-failure" - ? "warning" - : "muted"; + const tone = + report.outcome === "healthy" + ? "success" + : report.outcome === "disabled-hard-failure" + ? "danger" + : report.outcome === "warning-soft-failure" + ? "warning" + : "muted"; console.log( `${stylePromptText(prefix, tone)} ${stylePromptText(`${report.index + 1}. ${report.label}`, "accent")} ${stylePromptText("|", "muted")} ${styleAccountDetailText(report.message, tone === "success" ? "muted" : tone)}`, ); } } else { console.log(""); - console.log(stylePromptText("Per-account lines are hidden in dashboard settings.", "muted")); + console.log( + stylePromptText( + "Per-account lines are hidden in dashboard settings.", + "muted", + ), + ); } if (display.showRecommendations) { console.log(""); if (recommendation.recommendedIndex !== null) { const target = recommendation.recommendedIndex + 1; - console.log(`${stylePromptText("Best next account:", "accent")} ${stylePromptText(String(target), "success")}`); - console.log(`${stylePromptText("Why:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`); + console.log( + `${stylePromptText("Best next account:", "accent")} ${stylePromptText(String(target), "success")}`, + ); + console.log( + `${stylePromptText("Why:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`, + ); if (recommendation.recommendedIndex !== activeIndex) { - console.log(`${stylePromptText("Switch now with:", "accent")} codex auth switch ${target}`); + console.log( + `${stylePromptText("Switch now with:", "accent")} codex auth switch ${target}`, + ); } } else { - console.log(`${stylePromptText("Note:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`); + console.log( + `${stylePromptText("Note:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`, + ); } } if (workingQuotaCache && quotaCacheChanged) { @@ -3698,7 +4042,9 @@ async function runFix(args: string[]): Promise { } if (changed && options.dryRun) { - console.log(`\n${stylePromptText("Preview only: no changes were saved.", "warning")}`); + console.log( + `\n${stylePromptText("Preview only: no changes were saved.", "warning")}`, + ); } else if (changed) { console.log(`\n${stylePromptText("Saved updates.", "success")}`); } else { @@ -3736,7 +4082,8 @@ function hasPlaceholderEmail(value: string | undefined): boolean { function normalizeDoctorIndexes(storage: AccountStorageV3): boolean { const total = storage.accounts.length; - const nextActive = total === 0 ? 0 : Math.max(0, Math.min(storage.activeIndex, total - 1)); + const nextActive = + total === 0 ? 0 : Math.max(0, Math.min(storage.activeIndex, total - 1)); let changed = false; if (storage.activeIndex !== nextActive) { storage.activeIndex = nextActive; @@ -3746,8 +4093,10 @@ function normalizeDoctorIndexes(storage: AccountStorageV3): boolean { for (const family of MODEL_FAMILIES) { const raw = storage.activeIndexByFamily[family]; const fallback = storage.activeIndex; - const candidate = typeof raw === "number" && Number.isFinite(raw) ? raw : fallback; - const clamped = total === 0 ? 0 : Math.max(0, Math.min(candidate, total - 1)); + const candidate = + typeof raw === "number" && Number.isFinite(raw) ? raw : fallback; + const clamped = + total === 0 ? 0 : Math.max(0, Math.min(candidate, total - 1)); if (storage.activeIndexByFamily[family] !== clamped) { storage.activeIndexByFamily[family] = clamped; changed = true; @@ -3756,15 +4105,16 @@ function normalizeDoctorIndexes(storage: AccountStorageV3): boolean { return changed; } -function getDoctorRefreshTokenKey( - refreshToken: unknown, -): string | undefined { +function getDoctorRefreshTokenKey(refreshToken: unknown): string | undefined { if (typeof refreshToken !== "string") return undefined; const trimmed = refreshToken.trim(); return trimmed || undefined; } -function applyDoctorFixes(storage: AccountStorageV3): { changed: boolean; actions: DoctorFixAction[] } { +function applyDoctorFixes(storage: AccountStorageV3): { + changed: boolean; + actions: DoctorFixAction[]; +} { let changed = false; const actions: DoctorFixAction[] = []; @@ -3822,7 +4172,9 @@ function applyDoctorFixes(storage: AccountStorageV3): { changed: boolean; action } } - const enabledCount = storage.accounts.filter((account) => account.enabled !== false).length; + const enabledCount = storage.accounts.filter( + (account) => account.enabled !== false, + ).length; if (storage.accounts.length > 0 && enabledCount === 0) { const index = resolveActiveIndex(storage, "codex"); const candidate = storage.accounts[index] ?? storage.accounts[0]; @@ -3879,7 +4231,8 @@ async function runDoctor(args: string[]): Promise { addCheck({ key: "storage-readable", severity: stat.size > 0 ? "ok" : "warn", - message: stat.size > 0 ? "Storage file is readable" : "Storage file is empty", + message: + stat.size > 0 ? "Storage file is readable" : "Storage file is empty", details: `${stat.size} bytes`, }); } catch (error) { @@ -3912,20 +4265,27 @@ async function runDoctor(args: string[]): Promise { const parsed = JSON.parse(raw) as unknown; if (parsed && typeof parsed === "object") { const payload = parsed as Record; - const tokens = payload.tokens && typeof payload.tokens === "object" - ? (payload.tokens as Record) - : null; - const accessToken = tokens && typeof tokens.access_token === "string" - ? tokens.access_token - : undefined; - const idToken = tokens && typeof tokens.id_token === "string" - ? tokens.id_token - : undefined; - const accountIdFromFile = tokens && typeof tokens.account_id === "string" - ? tokens.account_id - : undefined; - const emailFromFile = typeof payload.email === "string" ? payload.email : undefined; - codexAuthEmail = sanitizeEmail(emailFromFile ?? extractAccountEmail(accessToken, idToken)); + const tokens = + payload.tokens && typeof payload.tokens === "object" + ? (payload.tokens as Record) + : null; + const accessToken = + tokens && typeof tokens.access_token === "string" + ? tokens.access_token + : undefined; + const idToken = + tokens && typeof tokens.id_token === "string" + ? tokens.id_token + : undefined; + const accountIdFromFile = + tokens && typeof tokens.account_id === "string" + ? tokens.account_id + : undefined; + const emailFromFile = + typeof payload.email === "string" ? payload.email : undefined; + codexAuthEmail = sanitizeEmail( + emailFromFile ?? extractAccountEmail(accessToken, idToken), + ); codexAuthAccountId = accountIdFromFile ?? extractAccountId(accessToken); } addCheck({ @@ -3960,7 +4320,9 @@ async function runDoctor(args: string[]): Promise { if (existsSync(codexConfigPath)) { try { const configRaw = await fs.readFile(codexConfigPath, "utf-8"); - const match = configRaw.match(/^\s*cli_auth_credentials_store\s*=\s*"([^"]+)"\s*$/m); + const match = configRaw.match( + /^\s*cli_auth_credentials_store\s*=\s*"([^"]+)"\s*$/m, + ); if (match?.[1]) { codexAuthStoreMode = match[1].trim(); } @@ -4029,7 +4391,8 @@ async function runDoctor(args: string[]): Promise { }); const activeIndex = resolveActiveIndex(storage, "codex"); - const activeExists = activeIndex >= 0 && activeIndex < storage.accounts.length; + const activeExists = + activeIndex >= 0 && activeIndex < storage.accounts.length; addCheck({ key: "active-index", severity: activeExists ? "ok" : "error", @@ -4038,7 +4401,9 @@ async function runDoctor(args: string[]): Promise { : "Active index is out of range", }); - const disabledCount = storage.accounts.filter((a) => a.enabled === false).length; + const disabledCount = storage.accounts.filter( + (a) => a.enabled === false, + ).length; addCheck({ key: "enabled-accounts", severity: disabledCount >= storage.accounts.length ? "error" : "ok", @@ -4117,7 +4482,10 @@ async function runDoctor(args: string[]): Promise { })), ); const recommendation = recommendForecastAccount(forecastResults); - if (recommendation.recommendedIndex !== null && recommendation.recommendedIndex !== activeIndex) { + if ( + recommendation.recommendedIndex !== null && + recommendation.recommendedIndex !== activeIndex + ) { addCheck({ key: "recommended-switch", severity: "warn", @@ -4136,8 +4504,10 @@ async function runDoctor(args: string[]): Promise { const activeAccount = storage.accounts[activeIndex]; const managerActiveEmail = sanitizeEmail(activeAccount?.email); const managerActiveAccountId = activeAccount?.accountId; - const codexActiveEmail = sanitizeEmail(codexCliState?.activeEmail) ?? codexAuthEmail; - const codexActiveAccountId = codexCliState?.activeAccountId ?? codexAuthAccountId; + const codexActiveEmail = + sanitizeEmail(codexCliState?.activeEmail) ?? codexAuthEmail; + const codexActiveAccountId = + codexCliState?.activeAccountId ?? codexAuthAccountId; const isEmailMismatch = !!managerActiveEmail && !!codexActiveEmail && @@ -4171,10 +4541,15 @@ async function runDoctor(args: string[]): Promise { message: `Prepared active-account token refresh for account ${activeIndex + 1} (dry-run)`, }); } else { - const refreshResult = await queuedRefresh(activeAccount.refreshToken); + const refreshResult = await queuedRefresh( + activeAccount.refreshToken, + ); if (refreshResult.type === "success") { const refreshedEmail = sanitizeEmail( - extractAccountEmail(refreshResult.access, refreshResult.idToken), + extractAccountEmail( + refreshResult.access, + refreshResult.idToken, + ), ); const refreshedAccountId = extractAccountId(refreshResult.access); activeAccount.accessToken = refreshResult.access; @@ -4196,7 +4571,10 @@ async function runDoctor(args: string[]): Promise { key: "doctor-refresh", severity: "warn", message: "Unable to refresh active account before Codex sync", - details: normalizeFailureDetail(refreshResult.message, refreshResult.reason), + details: normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ), }); } } @@ -4228,7 +4606,8 @@ async function runDoctor(args: string[]): Promise { addCheck({ key: "codex-active-sync", severity: "warn", - message: "Failed to sync manager active account into Codex auth state", + message: + "Failed to sync manager active account into Codex auth state", }); } } else { @@ -4273,10 +4652,13 @@ async function runDoctor(args: string[]): Promise { console.log("Doctor diagnostics"); console.log(`Storage: ${storagePath}`); - console.log(`Summary: ${summary.ok} ok, ${summary.warn} warnings, ${summary.error} errors`); + console.log( + `Summary: ${summary.ok} ok, ${summary.warn} warnings, ${summary.error} errors`, + ); console.log(""); for (const check of checks) { - const marker = check.severity === "ok" ? "✓" : check.severity === "warn" ? "!" : "✗"; + const marker = + check.severity === "ok" ? "✓" : check.severity === "warn" ? "!" : "✗"; console.log(`${marker} ${check.key}: ${check.message}`); if (check.details) { console.log(` ${check.details}`); @@ -4285,7 +4667,9 @@ async function runDoctor(args: string[]): Promise { if (options.fix) { console.log(""); if (fixActions.length > 0) { - console.log(`Auto-fix actions (${options.dryRun ? "dry-run" : "applied"}):`); + console.log( + `Auto-fix actions (${options.dryRun ? "dry-run" : "applied"}):`, + ); for (const action of fixActions) { console.log(` - ${action.message}`); } @@ -4355,7 +4739,9 @@ async function handleManageAction( const tokenResult = await runOAuthFlow(true, signInMode); if (tokenResult.type !== "success") { - console.error(`Refresh failed: ${tokenResult.message ?? tokenResult.reason ?? "unknown error"}`); + console.error( + `Refresh failed: ${tokenResult.message ?? tokenResult.reason ?? "unknown error"}`, + ); return; } @@ -4381,8 +4767,7 @@ async function runAuthLogin(args: string[]): Promise { setStoragePath(null); let pendingMenuQuotaRefresh: Promise | null = null; let menuQuotaRefreshStatus: string | undefined; - loginFlow: - while (true) { + loginFlow: while (true) { let existingStorage = await loadAccounts(); if (existingStorage && existingStorage.accounts.length > 0) { while (true) { @@ -4394,11 +4779,17 @@ async function runAuthLogin(args: string[]): Promise { const displaySettings = await loadDashboardDisplaySettings(); applyUiThemeFromDashboardSettings(displaySettings); const quotaCache = await loadQuotaCache(); - const shouldAutoFetchLimits = displaySettings.menuAutoFetchLimits ?? true; + const shouldAutoFetchLimits = + displaySettings.menuAutoFetchLimits ?? true; const showFetchStatus = displaySettings.menuShowFetchStatus ?? true; - const quotaTtlMs = displaySettings.menuQuotaTtlMs ?? DEFAULT_MENU_QUOTA_REFRESH_TTL_MS; + const quotaTtlMs = + displaySettings.menuQuotaTtlMs ?? DEFAULT_MENU_QUOTA_REFRESH_TTL_MS; if (shouldAutoFetchLimits && !pendingMenuQuotaRefresh) { - const staleCount = countMenuQuotaRefreshTargets(currentStorage, quotaCache, quotaTtlMs); + const staleCount = countMenuQuotaRefreshTargets( + currentStorage, + quotaCache, + quotaTtlMs, + ); if (staleCount > 0) { if (showFetchStatus) { menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [0/${staleCount}]`; @@ -4426,7 +4817,9 @@ async function runAuthLogin(args: string[]): Promise { toExistingAccountInfo(currentStorage, quotaCache, displaySettings), { flaggedCount: flaggedStorage.accounts.length, - statusMessage: showFetchStatus ? () => menuQuotaRefreshStatus : undefined, + statusMessage: showFetchStatus + ? () => menuQuotaRefreshStatus + : undefined, }, ); @@ -4435,27 +4828,47 @@ async function runAuthLogin(args: string[]): Promise { return 0; } if (menuResult.mode === "check") { - await runActionPanel("Quick Check", "Checking local session + live status", async () => { - await runHealthCheck({ forceRefresh: false, liveProbe: true }); - }, displaySettings); + await runActionPanel( + "Quick Check", + "Checking local session + live status", + async () => { + await runHealthCheck({ forceRefresh: false, liveProbe: true }); + }, + displaySettings, + ); continue; } if (menuResult.mode === "deep-check") { - await runActionPanel("Deep Check", "Refreshing and testing all accounts", async () => { - await runHealthCheck({ forceRefresh: true, liveProbe: true }); - }, displaySettings); + await runActionPanel( + "Deep Check", + "Refreshing and testing all accounts", + async () => { + await runHealthCheck({ forceRefresh: true, liveProbe: true }); + }, + displaySettings, + ); continue; } if (menuResult.mode === "forecast") { - await runActionPanel("Best Account", "Comparing accounts", async () => { - await runForecast(["--live"]); - }, displaySettings); + await runActionPanel( + "Best Account", + "Comparing accounts", + async () => { + await runForecast(["--live"]); + }, + displaySettings, + ); continue; } if (menuResult.mode === "fix") { - await runActionPanel("Auto-Fix", "Checking and fixing common issues", async () => { - await runFix(["--live"]); - }, displaySettings); + await runActionPanel( + "Auto-Fix", + "Checking and fixing common issues", + async () => { + await runFix(["--live"]); + }, + displaySettings, + ); continue; } if (menuResult.mode === "settings") { @@ -4463,27 +4876,45 @@ async function runAuthLogin(args: string[]): Promise { continue; } if (menuResult.mode === "verify-flagged") { - await runActionPanel("Problem Account Check", "Checking problem accounts", async () => { - await runVerifyFlagged([]); - }, displaySettings); + await runActionPanel( + "Problem Account Check", + "Checking problem accounts", + async () => { + await runVerifyFlagged([]); + }, + displaySettings, + ); continue; } if (menuResult.mode === "fresh" && menuResult.deleteAll) { - await runActionPanel("Reset Accounts", "Deleting all saved accounts", async () => { - await clearAccountsAndReset(); - console.log("Cleared saved accounts from active storage. Recovery snapshots remain available."); - }, displaySettings); + await runActionPanel( + "Reset Accounts", + "Deleting all saved accounts", + async () => { + await clearAccountsAndReset(); + console.log( + "Cleared saved accounts from active storage. Recovery snapshots remain available.", + ); + }, + displaySettings, + ); continue; } if (menuResult.mode === "manage") { - const requiresInteractiveOAuth = typeof menuResult.refreshAccountIndex === "number"; + const requiresInteractiveOAuth = + typeof menuResult.refreshAccountIndex === "number"; if (requiresInteractiveOAuth) { await handleManageAction(currentStorage, menuResult); continue; } - await runActionPanel("Applying Change", "Updating selected account", async () => { - await handleManageAction(currentStorage, menuResult); - }, displaySettings); + await runActionPanel( + "Applying Change", + "Updating selected account", + async () => { + await handleManageAction(currentStorage, menuResult); + }, + displaySettings, + ); continue; } if (menuResult.mode === "add") { @@ -4496,7 +4927,9 @@ async function runAuthLogin(args: string[]): Promise { let existingCount = refreshedStorage?.accounts.length ?? 0; let forceNewLogin = existingCount > 0; let onboardingBackupDiscoveryWarning: string | null = null; - const loadNamedBackupsForOnboarding = async (): Promise => { + const loadNamedBackupsForOnboarding = async (): Promise< + NamedBackupSummary[] + > => { if (existingCount > 0) { onboardingBackupDiscoveryWarning = null; return []; @@ -4513,9 +4946,7 @@ async function runAuthLogin(args: string[]): Promise { if (code && code !== "ENOENT") { onboardingBackupDiscoveryWarning = "Named backup discovery failed. Continuing with browser or manual sign-in only."; - console.warn( - onboardingBackupDiscoveryWarning, - ); + console.warn(onboardingBackupDiscoveryWarning); } else { onboardingBackupDiscoveryWarning = null; } @@ -4525,16 +4956,19 @@ async function runAuthLogin(args: string[]): Promise { let namedBackups = await loadNamedBackupsForOnboarding(); while (true) { const latestNamedBackup = namedBackups[0] ?? null; - const preferManualMode = loginOptions.manual || isBrowserLaunchSuppressed(); + const preferManualMode = + loginOptions.manual || isBrowserLaunchSuppressed(); const signInMode = preferManualMode ? "manual" : await promptOAuthSignInMode( - latestNamedBackup, - onboardingBackupDiscoveryWarning, - ); + latestNamedBackup, + onboardingBackupDiscoveryWarning, + ); if (signInMode === "cancel") { if (existingCount > 0) { - console.log(stylePromptText(UI_COPY.oauth.cancelledBackToMenu, "muted")); + console.log( + stylePromptText(UI_COPY.oauth.cancelledBackToMenu, "muted"), + ); continue loginFlow; } console.log("Cancelled."); @@ -4546,15 +4980,18 @@ async function runAuthLogin(args: string[]): Promise { namedBackups = await loadNamedBackupsForOnboarding(); continue; } - const restoreMode = await promptBackupRestoreMode(latestAvailableBackup); + const restoreMode = await promptBackupRestoreMode( + latestAvailableBackup, + ); if (restoreMode === "back") { namedBackups = await loadNamedBackupsForOnboarding(); continue; } - const selectedBackup = restoreMode === "manual" - ? await promptManualBackupSelection(namedBackups) - : latestAvailableBackup; + const selectedBackup = + restoreMode === "manual" + ? await promptManualBackupSelection(namedBackups) + : latestAvailableBackup; if (!selectedBackup) { namedBackups = await loadNamedBackupsForOnboarding(); continue; @@ -4603,13 +5040,16 @@ async function runAuthLogin(args: string[]): Promise { displaySettings, ); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = + error instanceof Error ? error.message : String(error); if (error instanceof StorageError) { console.error(formatStorageErrorHint(error, selectedBackup.path)); } else { console.error(`Backup restore failed: ${message}`); } - const storageAfterRestoreAttempt = await loadAccounts().catch(() => null); + const storageAfterRestoreAttempt = await loadAccounts().catch( + () => null, + ); if ((storageAfterRestoreAttempt?.accounts.length ?? 0) > 0) { continue loginFlow; } @@ -4627,13 +5067,17 @@ async function runAuthLogin(args: string[]): Promise { if (tokenResult.type !== "success") { if (isUserCancelledOAuth(tokenResult)) { if (existingCount > 0) { - console.log(stylePromptText(UI_COPY.oauth.cancelledBackToMenu, "muted")); + console.log( + stylePromptText(UI_COPY.oauth.cancelledBackToMenu, "muted"), + ); continue loginFlow; } console.log("Cancelled."); return 0; } - console.error(`Login failed: ${tokenResult.message ?? tokenResult.reason ?? "unknown error"}`); + console.error( + `Login failed: ${tokenResult.message ?? tokenResult.reason ?? "unknown error"}`, + ); return 1; } @@ -4648,7 +5092,9 @@ async function runAuthLogin(args: string[]): Promise { onboardingBackupDiscoveryWarning = null; console.log(`Added account. Total: ${count}`); if (count >= ACCOUNT_LIMITS.MAX_ACCOUNTS) { - console.log(`Reached maximum account limit (${ACCOUNT_LIMITS.MAX_ACCOUNTS}).`); + console.log( + `Reached maximum account limit (${ACCOUNT_LIMITS.MAX_ACCOUNTS}).`, + ); break; } @@ -4656,7 +5102,6 @@ async function runAuthLogin(args: string[]): Promise { if (!addAnother) break; forceNewLogin = true; } - continue loginFlow; } } @@ -4680,7 +5125,9 @@ async function runSwitch(args: string[]): Promise { return 1; } if (targetIndex < 0 || targetIndex >= storage.accounts.length) { - console.error(`Index out of range. Valid range: 1-${storage.accounts.length}`); + console.error( + `Index out of range. Valid range: 1-${storage.accounts.length}`, + ); return 1; } @@ -4765,7 +5212,9 @@ async function persistAndSyncSelectedAccount({ const refreshResult = await queuedRefresh(account.refreshToken); if (refreshResult.type === "success") { const tokenAccountId = extractAccountId(refreshResult.access); - const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)); + const nextEmail = sanitizeEmail( + extractAccountEmail(refreshResult.access, refreshResult.idToken), + ); if (account.refreshToken !== refreshResult.refresh) { account.refreshToken = refreshResult.refresh; } @@ -4828,7 +5277,9 @@ async function runBest(args: string[]): Promise { const storage = await loadAccounts(); if (!storage || storage.accounts.length === 0) { if (options.json) { - console.log(JSON.stringify({ error: "No accounts configured." }, null, 2)); + console.log( + JSON.stringify({ error: "No accounts configured." }, null, 2), + ); } else { console.log("No accounts configured."); } @@ -4837,7 +5288,10 @@ async function runBest(args: string[]): Promise { const now = Date.now(); const refreshFailures = new Map(); - const liveQuotaByIndex = new Map>>(); + const liveQuotaByIndex = new Map< + number, + Awaited> + >(); const probeIdTokenByIndex = new Map(); const probeRefreshedIndices = new Set(); const probeErrors: string[] = []; @@ -4863,13 +5317,17 @@ async function runBest(args: string[]): Promise { if (account.enabled === false) continue; let probeAccessToken = account.accessToken; - let probeAccountId = account.accountId ?? extractAccountId(account.accessToken); + let probeAccountId = + account.accountId ?? extractAccountId(account.accessToken); if (!hasUsableAccessToken(account, now)) { const refreshResult = await queuedRefresh(account.refreshToken); if (refreshResult.type !== "success") { refreshFailures.set(i, { ...refreshResult, - message: normalizeFailureDetail(refreshResult.message, refreshResult.reason), + message: normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ), }); continue; } @@ -4910,7 +5368,9 @@ async function runBest(args: string[]): Promise { } if (!probeAccessToken || !probeAccountId) { - probeErrors.push(`${formatAccountLabel(account, i)}: missing accountId for live probe`); + probeErrors.push( + `${formatAccountLabel(account, i)}: missing accountId for live probe`, + ); continue; } @@ -4945,10 +5405,16 @@ async function runBest(args: string[]): Promise { if (recommendation.recommendedIndex === null) { await persistProbeChangesIfNeeded(); if (options.json) { - console.log(JSON.stringify({ - error: recommendation.reason, - ...(probeErrors.length > 0 ? { probeErrors } : {}), - }, null, 2)); + console.log( + JSON.stringify( + { + error: recommendation.reason, + ...(probeErrors.length > 0 ? { probeErrors } : {}), + }, + null, + 2, + ), + ); } else { console.log(`No best account available: ${recommendation.reason}`); printProbeNotes(); @@ -4961,7 +5427,9 @@ async function runBest(args: string[]): Promise { if (!bestAccount) { await persistProbeChangesIfNeeded(); if (options.json) { - console.log(JSON.stringify({ error: "Best account not found." }, null, 2)); + console.log( + JSON.stringify({ error: "Best account not found." }, null, 2), + ); } else { console.log("Best account not found."); } @@ -4972,7 +5440,8 @@ async function runBest(args: string[]): Promise { const currentIndex = resolveActiveIndex(storage, "codex"); if (currentIndex === bestIndex) { const shouldSyncCurrentBest = - probeRefreshedIndices.has(bestIndex) || probeIdTokenByIndex.has(bestIndex); + probeRefreshedIndices.has(bestIndex) || + probeIdTokenByIndex.has(bestIndex); let alreadyBestSynced: boolean | undefined; if (changed) { bestAccount.lastUsed = now; @@ -4990,19 +5459,31 @@ async function runBest(args: string[]): Promise { : {}), }); if (!alreadyBestSynced && !options.json) { - console.warn("Codex auth sync did not complete. Multi-auth routing will still use this account."); + console.warn( + "Codex auth sync did not complete. Multi-auth routing will still use this account.", + ); } } if (options.json) { - console.log(JSON.stringify({ - message: `Already on best account: ${formatAccountLabel(bestAccount, bestIndex)}`, - accountIndex: bestIndex + 1, - reason: recommendation.reason, - ...(alreadyBestSynced !== undefined ? { synced: alreadyBestSynced } : {}), - ...(probeErrors.length > 0 ? { probeErrors } : {}), - }, null, 2)); + console.log( + JSON.stringify( + { + message: `Already on best account: ${formatAccountLabel(bestAccount, bestIndex)}`, + accountIndex: bestIndex + 1, + reason: recommendation.reason, + ...(alreadyBestSynced !== undefined + ? { synced: alreadyBestSynced } + : {}), + ...(probeErrors.length > 0 ? { probeErrors } : {}), + }, + null, + 2, + ), + ); } else { - console.log(`Already on best account ${bestIndex + 1}: ${formatAccountLabel(bestAccount, bestIndex)}`); + console.log( + `Already on best account ${bestIndex + 1}: ${formatAccountLabel(bestAccount, bestIndex)}`, + ); console.log(`Reason: ${recommendation.reason}`); printProbeNotes(); } @@ -5020,20 +5501,30 @@ async function runBest(args: string[]): Promise { }); if (options.json) { - console.log(JSON.stringify({ - message: `Switched to best account: ${formatAccountLabel(bestAccount, targetIndex)}`, - accountIndex: parsed, - reason: recommendation.reason, - synced, - wasDisabled, - ...(probeErrors.length > 0 ? { probeErrors } : {}), - }, null, 2)); + console.log( + JSON.stringify( + { + message: `Switched to best account: ${formatAccountLabel(bestAccount, targetIndex)}`, + accountIndex: parsed, + reason: recommendation.reason, + synced, + wasDisabled, + ...(probeErrors.length > 0 ? { probeErrors } : {}), + }, + null, + 2, + ), + ); } else { - console.log(`Switched to best account ${parsed}: ${formatAccountLabel(bestAccount, targetIndex)}${wasDisabled ? " (re-enabled)" : ""}`); + console.log( + `Switched to best account ${parsed}: ${formatAccountLabel(bestAccount, targetIndex)}${wasDisabled ? " (re-enabled)" : ""}`, + ); console.log(`Reason: ${recommendation.reason}`); printProbeNotes(); if (!synced) { - console.warn("Codex auth sync did not complete. Multi-auth routing will still use this account."); + console.warn( + "Codex auth sync did not complete. Multi-auth routing will still use this account.", + ); } } return 0; @@ -5067,7 +5558,9 @@ export async function autoSyncActiveAccountToCodex(): Promise { const refreshResult = await queuedRefresh(account.refreshToken); if (refreshResult.type === "success") { const tokenAccountId = extractAccountId(refreshResult.access); - const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)); + const nextEmail = sanitizeEmail( + extractAccountEmail(refreshResult.access, refreshResult.idToken), + ); if (account.refreshToken !== refreshResult.refresh) { account.refreshToken = refreshResult.refresh; changed = true; diff --git a/lib/forecast.ts b/lib/forecast.ts index 30455746..146defde 100644 --- a/lib/forecast.ts +++ b/lib/forecast.ts @@ -33,6 +33,24 @@ export interface ForecastRecommendation { reason: string; } +export interface ForecastExplanationAccount { + index: number; + label: string; + isCurrent: boolean; + availability: ForecastAvailability; + riskScore: number; + riskLevel: ForecastRiskLevel; + waitMs: number; + reasons: string[]; + selected: boolean; +} + +export interface ForecastExplanation { + recommendedIndex: number | null; + recommendationReason: string; + considered: ForecastExplanationAccount[]; +} + export interface ForecastSummary { total: number; ready: number; @@ -75,7 +93,8 @@ function redactSensitiveReason(value: string): string { function summarizeRefreshFailure(failure: TokenFailure): string { const reasonCode = failure.reason?.trim(); if (reasonCode && reasonCode.length > 0) { - const statusCode = typeof failure.statusCode === "number" ? ` (${failure.statusCode})` : ""; + const statusCode = + typeof failure.statusCode === "number" ? ` (${failure.statusCode})` : ""; return `${reasonCode}${statusCode}`; } const fallback = failure.message?.trim() || "refresh failed"; @@ -111,7 +130,10 @@ function getRateLimitResetTimeForFamily( function getLiveQuotaWaitMs(snapshot: CodexQuotaSnapshot, now: number): number { const waits: number[] = []; - for (const resetAt of [snapshot.primary.resetAtMs, snapshot.secondary.resetAtMs]) { + for (const resetAt of [ + snapshot.primary.resetAtMs, + snapshot.secondary.resetAtMs, + ]) { if (typeof resetAt !== "number") continue; if (!Number.isFinite(resetAt)) continue; const remaining = resetAt - now; @@ -120,8 +142,12 @@ function getLiveQuotaWaitMs(snapshot: CodexQuotaSnapshot, now: number): number { return waits.length > 0 ? Math.max(...waits) : 0; } -function describeQuotaUsage(label: string, usedPercent: number | undefined): string | null { - if (typeof usedPercent !== "number" || !Number.isFinite(usedPercent)) return null; +function describeQuotaUsage( + label: string, + usedPercent: number | undefined, +): string | null { + if (typeof usedPercent !== "number" || !Number.isFinite(usedPercent)) + return null; const bounded = Math.max(0, Math.min(100, Math.round(usedPercent))); return `${label} quota ${bounded}% used`; } @@ -138,12 +164,18 @@ export function isHardRefreshFailure(failure: TokenFailure): boolean { ); } -function appendWaitReason(reasons: string[], prefix: string, waitMs: number): void { +function appendWaitReason( + reasons: string[], + prefix: string, + waitMs: number, +): void { if (waitMs <= 0) return; reasons.push(`${prefix} ${formatWaitTime(waitMs)}`); } -export function evaluateForecastAccount(input: ForecastAccountInput): ForecastAccountResult { +export function evaluateForecastAccount( + input: ForecastAccountInput, +): ForecastAccountResult { const { account, index, isCurrent, now } = input; const reasons: string[] = []; let availability: ForecastAvailability = "ready"; @@ -172,7 +204,10 @@ export function evaluateForecastAccount(input: ForecastAccountInput): ForecastAc } } - if (typeof account.coolingDownUntil === "number" && account.coolingDownUntil > now) { + if ( + typeof account.coolingDownUntil === "number" && + account.coolingDownUntil > now + ) { const remaining = account.coolingDownUntil - now; waitMs = Math.max(waitMs, remaining); if (availability === "ready") availability = "delayed"; @@ -180,7 +215,11 @@ export function evaluateForecastAccount(input: ForecastAccountInput): ForecastAc appendWaitReason(reasons, "cooldown remaining", remaining); } - const rateLimitResetAt = getRateLimitResetTimeForFamily(account, now, "codex"); + const rateLimitResetAt = getRateLimitResetTimeForFamily( + account, + now, + "codex", + ); if (typeof rateLimitResetAt === "number") { const remaining = Math.max(0, rateLimitResetAt - now); waitMs = Math.max(waitMs, remaining); @@ -193,7 +232,8 @@ export function evaluateForecastAccount(input: ForecastAccountInput): ForecastAc if (quota) { const primaryUsed = quota.primary.usedPercent ?? 0; const secondaryUsed = quota.secondary.usedPercent ?? 0; - const quotaPressure = quota.status === 429 || primaryUsed >= 90 || secondaryUsed >= 90; + const quotaPressure = + quota.status === 429 || primaryUsed >= 90 || secondaryUsed >= 90; if (quota.status === 429) { availability = availability === "unavailable" ? "unavailable" : "delayed"; @@ -206,9 +246,15 @@ export function evaluateForecastAccount(input: ForecastAccountInput): ForecastAc availability = "delayed"; } - const primaryUsage = describeQuotaUsage("primary", quota.primary.usedPercent); + const primaryUsage = describeQuotaUsage( + "primary", + quota.primary.usedPercent, + ); if (primaryUsage) reasons.push(primaryUsage); - const secondaryUsage = describeQuotaUsage("secondary", quota.secondary.usedPercent); + const secondaryUsage = describeQuotaUsage( + "secondary", + quota.secondary.usedPercent, + ); if (secondaryUsage) reasons.push(secondaryUsage); if (primaryUsed >= 98 || secondaryUsed >= 98) { @@ -222,9 +268,15 @@ export function evaluateForecastAccount(input: ForecastAccountInput): ForecastAc } } - const hasLastUsed = typeof account.lastUsed === "number" && Number.isFinite(account.lastUsed) && account.lastUsed > 0; + const hasLastUsed = + typeof account.lastUsed === "number" && + Number.isFinite(account.lastUsed) && + account.lastUsed > 0; const lastUsedAge = hasLastUsed ? now - account.lastUsed : null; - if (lastUsedAge !== null && (!Number.isFinite(lastUsedAge) || lastUsedAge < 0)) { + if ( + lastUsedAge !== null && + (!Number.isFinite(lastUsedAge) || lastUsedAge < 0) + ) { riskScore += 5; } else if (lastUsedAge !== null && lastUsedAge > 7 * 24 * 60 * 60 * 1000) { riskScore += 10; @@ -245,11 +297,16 @@ export function evaluateForecastAccount(input: ForecastAccountInput): ForecastAc }; } -export function evaluateForecastAccounts(inputs: ForecastAccountInput[]): ForecastAccountResult[] { +export function evaluateForecastAccounts( + inputs: ForecastAccountInput[], +): ForecastAccountResult[] { return inputs.map((input) => evaluateForecastAccount(input)); } -function compareForecastResults(a: ForecastAccountResult, b: ForecastAccountResult): number { +function compareForecastResults( + a: ForecastAccountResult, + b: ForecastAccountResult, +): number { if (a.availability !== b.availability) { const rank: Record = { ready: 0, @@ -259,7 +316,11 @@ function compareForecastResults(a: ForecastAccountResult, b: ForecastAccountResu return rank[a.availability] - rank[b.availability]; } - if (a.availability === "delayed" && b.availability === "delayed" && a.waitMs !== b.waitMs) { + if ( + a.availability === "delayed" && + b.availability === "delayed" && + a.waitMs !== b.waitMs + ) { return a.waitMs - b.waitMs; } @@ -274,12 +335,17 @@ function compareForecastResults(a: ForecastAccountResult, b: ForecastAccountResu return a.index - b.index; } -export function recommendForecastAccount(results: ForecastAccountResult[]): ForecastRecommendation { - const candidates = results.filter((result) => !result.disabled && !result.hardFailure); +export function recommendForecastAccount( + results: ForecastAccountResult[], +): ForecastRecommendation { + const candidates = results.filter( + (result) => !result.disabled && !result.hardFailure, + ); if (candidates.length === 0) { return { recommendedIndex: null, - reason: "No healthy accounts are available. Run `codex auth login` to add a fresh account.", + reason: + "No healthy accounts are available. Run `codex auth login` to add a fresh account.", }; } @@ -305,13 +371,38 @@ export function recommendForecastAccount(results: ForecastAccountResult[]): Fore }; } -export function summarizeForecast(results: ForecastAccountResult[]): ForecastSummary { +export function summarizeForecast( + results: ForecastAccountResult[], +): ForecastSummary { return { total: results.length, ready: results.filter((result) => result.availability === "ready").length, - delayed: results.filter((result) => result.availability === "delayed").length, - unavailable: results.filter((result) => result.availability === "unavailable").length, + delayed: results.filter((result) => result.availability === "delayed") + .length, + unavailable: results.filter( + (result) => result.availability === "unavailable", + ).length, highRisk: results.filter((result) => result.riskLevel === "high").length, }; } +export function buildForecastExplanation( + results: ForecastAccountResult[], + recommendation: ForecastRecommendation, +): ForecastExplanation { + return { + recommendedIndex: recommendation.recommendedIndex, + recommendationReason: recommendation.reason, + considered: results.map((result) => ({ + index: result.index, + label: result.label, + isCurrent: result.isCurrent, + availability: result.availability, + riskScore: result.riskScore, + riskLevel: result.riskLevel, + waitMs: result.waitMs, + reasons: result.reasons, + selected: recommendation.recommendedIndex === result.index, + })), + }; +} diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 930cf8fb..9bd16887 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -96,7 +96,10 @@ vi.mock("../lib/accounts.js", () => ({ tokenId: string | undefined, ) => { if (!storedAccountId) return tokenId; - if (currentAccountIdSource === "org" || currentAccountIdSource === "manual") { + if ( + currentAccountIdSource === "org" || + currentAccountIdSource === "manual" + ) { return storedAccountId; } return tokenId ?? storedAccountId; @@ -107,10 +110,16 @@ vi.mock("../lib/accounts.js", () => ({ ), selectBestAccountCandidate: vi.fn(() => null), shouldUpdateAccountIdFromToken: vi.fn( - (currentAccountIdSource: string | undefined, currentAccountId: string | undefined) => { + ( + currentAccountIdSource: string | undefined, + currentAccountId: string | undefined, + ) => { if (!currentAccountId) return true; if (!currentAccountIdSource) return true; - return currentAccountIdSource === "token" || currentAccountIdSource === "id_token"; + return ( + currentAccountIdSource === "token" || + currentAccountIdSource === "id_token" + ); }, ), })); @@ -499,33 +508,31 @@ describe("codex manager cli commands", () => { version: 1, accounts: [], }); - withAccountStorageTransactionMock.mockImplementation( - async (handler) => { - const current = await loadAccountsMock(); - return handler( - current == null - ? { + withAccountStorageTransactionMock.mockImplementation(async (handler) => { + const current = await loadAccountsMock(); + return handler( + current == null + ? { version: 3, accounts: [], activeIndex: 0, activeIndexByFamily: {}, } - : structuredClone(current), - async (storage: unknown) => saveAccountsMock(storage), - ); - }, - ); + : structuredClone(current), + async (storage: unknown) => saveAccountsMock(storage), + ); + }); withAccountAndFlaggedStorageTransactionMock.mockImplementation( async (handler) => { const current = await loadAccountsMock(); let snapshot = current == null ? { - version: 3, - accounts: [], - activeIndex: 0, - activeIndexByFamily: {}, - } + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + } : structuredClone(current); return handler( structuredClone(snapshot), @@ -576,7 +583,9 @@ describe("codex manager cli commands", () => { const { formatBackupSavedAt } = await import("../lib/codex-manager.js"); try { - expect(formatBackupSavedAt(1_710_000_000_000)).toBe("Localized Saved Time"); + expect(formatBackupSavedAt(1_710_000_000_000)).toBe( + "Localized Saved Time", + ); expect(localeSpy).toHaveBeenCalledWith(undefined, { month: "short", day: "numeric", @@ -631,6 +640,57 @@ describe("codex manager cli commands", () => { expect(payload.recommendation.recommendedIndex).toBe(0); }); + it("runs forecast in json explain mode", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + }, + { + email: "b@example.com", + refreshToken: "refresh-b", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: false, + }, + ], + }); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli([ + "auth", + "forecast", + "--json", + "--explain", + ]); + expect(exitCode).toBe(0); + expect(errorSpy).not.toHaveBeenCalled(); + + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + explanation: { + recommendedIndex: number | null; + considered: Array<{ + index: number; + selected: boolean; + reasons: string[]; + }>; + }; + }; + expect(payload.explanation.recommendedIndex).toBe(0); + expect(payload.explanation.considered).toHaveLength(2); + expect(payload.explanation.considered[0]?.selected).toBe(true); + }); + it("does not mutate loaded quota cache when live forecast save fails", async () => { const now = Date.now(); const originalQuotaCache = { @@ -859,9 +919,7 @@ describe("codex manager cli commands", () => { expect(logSpy.mock.calls[0]?.[0]).toBe("Implemented features (41)"); expect( logSpy.mock.calls.some((call) => - String(call[0]).includes( - "41. Auto-switch to best account command", - ), + String(call[0]).includes("41. Auto-switch to best account command"), ), ).toBe(true); }); @@ -930,10 +988,17 @@ describe("codex manager cli commands", () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "best", "--model", "gpt-5.1"]); + const exitCode = await runCodexMultiAuthCli([ + "auth", + "best", + "--model", + "gpt-5.1", + ]); expect(exitCode).toBe(1); - expect(errorSpy).toHaveBeenCalledWith("--model requires --live for codex auth best"); + expect(errorSpy).toHaveBeenCalledWith( + "--model requires --live for codex auth best", + ); expect(loadAccountsMock).not.toHaveBeenCalled(); expect(saveAccountsMock).not.toHaveBeenCalled(); expect(fetchCodexQuotaSnapshotMock).not.toHaveBeenCalled(); @@ -1074,7 +1139,9 @@ describe("codex manager cli commands", () => { ]); expect(exitCode).toBe(0); - expect(withAccountAndFlaggedStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(withAccountAndFlaggedStorageTransactionMock).toHaveBeenCalledTimes( + 1, + ); expect(saveAccountsMock).toHaveBeenCalledWith( expect.objectContaining({ accounts: expect.arrayContaining([ @@ -1131,7 +1198,9 @@ describe("codex manager cli commands", () => { ]); expect(exitCode).toBe(0); - expect(withAccountAndFlaggedStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(withAccountAndFlaggedStorageTransactionMock).toHaveBeenCalledTimes( + 1, + ); const savedStorage = saveAccountsMock.mock.calls.at(-1)?.[0]; expect(savedStorage).toEqual( expect.objectContaining({ @@ -1199,7 +1268,11 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); const savedStorage = saveAccountsMock.mock.calls.at(-1)?.[0] as { - accounts: Array<{ accountId?: string; accountIdSource?: string; refreshToken?: string }>; + accounts: Array<{ + accountId?: string; + accountIdSource?: string; + refreshToken?: string; + }>; }; expect(savedStorage.accounts[0]).toEqual( expect.objectContaining({ @@ -1556,21 +1629,19 @@ describe("codex manager cli commands", () => { }); const accountsModule = await import("../lib/accounts.js"); const extractAccountIdMock = vi.mocked(accountsModule.extractAccountId); - const extractAccountEmailMock = vi.mocked(accountsModule.extractAccountEmail); - extractAccountIdMock.mockImplementation( - (accessToken?: string) => { - if (accessToken === "access-alpha-stale") return "shared-workspace"; - if (accessToken === "access-alpha-refreshed") return "shared-workspace"; - if (accessToken === "access-beta") return "shared-workspace"; - return "acc_test"; - }, - ); - extractAccountEmailMock.mockImplementation( - (accessToken?: string) => { - if (accessToken === "access-alpha-refreshed") return "owner@example.com"; - return undefined; - }, + const extractAccountEmailMock = vi.mocked( + accountsModule.extractAccountEmail, ); + extractAccountIdMock.mockImplementation((accessToken?: string) => { + if (accessToken === "access-alpha-stale") return "shared-workspace"; + if (accessToken === "access-alpha-refreshed") return "shared-workspace"; + if (accessToken === "access-beta") return "shared-workspace"; + return "acc_test"; + }); + extractAccountEmailMock.mockImplementation((accessToken?: string) => { + if (accessToken === "access-alpha-refreshed") return "owner@example.com"; + return undefined; + }); fetchCodexQuotaSnapshotMock .mockResolvedValueOnce({ status: 200, @@ -1690,21 +1761,19 @@ describe("codex manager cli commands", () => { }); const accountsModule = await import("../lib/accounts.js"); const extractAccountIdMock = vi.mocked(accountsModule.extractAccountId); - const extractAccountEmailMock = vi.mocked(accountsModule.extractAccountEmail); - extractAccountIdMock.mockImplementation( - (accessToken?: string) => { - if (accessToken === "access-alpha-stale") return "shared-workspace"; - if (accessToken === "access-alpha-refreshed") return "shared-workspace"; - if (accessToken === "access-beta") return "shared-workspace"; - return "acc_test"; - }, - ); - extractAccountEmailMock.mockImplementation( - (accessToken?: string) => { - if (accessToken === "access-alpha-refreshed") return "owner@example.com"; - return undefined; - }, + const extractAccountEmailMock = vi.mocked( + accountsModule.extractAccountEmail, ); + extractAccountIdMock.mockImplementation((accessToken?: string) => { + if (accessToken === "access-alpha-stale") return "shared-workspace"; + if (accessToken === "access-alpha-refreshed") return "shared-workspace"; + if (accessToken === "access-beta") return "shared-workspace"; + return "acc_test"; + }); + extractAccountEmailMock.mockImplementation((accessToken?: string) => { + if (accessToken === "access-alpha-refreshed") return "owner@example.com"; + return undefined; + }); fetchCodexQuotaSnapshotMock .mockResolvedValueOnce({ status: 200, @@ -1850,10 +1919,12 @@ describe("codex manager cli commands", () => { ); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - await expect(runCodexMultiAuthCli(["auth", "check"])).rejects.toMatchObject({ - code: "EBUSY", - message: "save failed", - }); + await expect(runCodexMultiAuthCli(["auth", "check"])).rejects.toMatchObject( + { + code: "EBUSY", + message: "save failed", + }, + ); expect(originalQuotaCache).toEqual({ byAccountId: {}, byEmail: {}, @@ -1959,7 +2030,9 @@ describe("codex manager cli commands", () => { }, ], }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -2080,7 +2153,12 @@ describe("codex manager cli commands", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "best", "--live", "--json"]); + const exitCode = await runCodexMultiAuthCli([ + "auth", + "best", + "--live", + "--json", + ]); expect(exitCode).toBe(0); expect(setCodexCliActiveSelectionMock).toHaveBeenCalledTimes(1); @@ -2128,7 +2206,9 @@ describe("codex manager cli commands", () => { }, ], }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -2282,9 +2362,11 @@ describe("codex manager cli commands", () => { ); expect(saveAccountsMock).not.toHaveBeenCalled(); expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); - expect(logSpy.mock.calls.some((call) => - String(call[0]).includes("Already on best account 1"), - )).toBe(true); + expect( + logSpy.mock.calls.some((call) => + String(call[0]).includes("Already on best account 1"), + ), + ).toBe(true); }); it("syncs refreshed current best account during live best check", async () => { @@ -2304,7 +2386,9 @@ describe("codex manager cli commands", () => { }, ], }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -2350,9 +2434,11 @@ describe("codex manager cli commands", () => { idToken: "id-best-next", }), ); - expect(logSpy.mock.calls.some((call) => - String(call[0]).includes("Already on best account 1"), - )).toBe(true); + expect( + logSpy.mock.calls.some((call) => + String(call[0]).includes("Already on best account 1"), + ), + ).toBe(true); }); it("reports synced=false in already-best json output when live sync fails", async () => { @@ -2372,7 +2458,9 @@ describe("codex manager cli commands", () => { }, ], }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -2402,7 +2490,12 @@ describe("codex manager cli commands", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "best", "--live", "--json"]); + const exitCode = await runCodexMultiAuthCli([ + "auth", + "best", + "--live", + "--json", + ]); expect(exitCode).toBe(0); expect(saveAccountsMock).toHaveBeenCalledTimes(1); @@ -2506,7 +2599,9 @@ describe("codex manager cli commands", () => { }, ], }); - fetchCodexQuotaSnapshotMock.mockRejectedValueOnce(new Error("network timeout")); + fetchCodexQuotaSnapshotMock.mockRejectedValueOnce( + new Error("network timeout"), + ); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); @@ -2515,15 +2610,21 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(saveAccountsMock).not.toHaveBeenCalled(); expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); - expect(logSpy.mock.calls.some((call) => - String(call[0]).includes("Already on best account 1"), - )).toBe(true); - expect(logSpy.mock.calls.some((call) => - String(call[0]).includes("Live check notes (1)"), - )).toBe(true); - expect(logSpy.mock.calls.some((call) => - String(call[0]).includes("network timeout"), - )).toBe(true); + expect( + logSpy.mock.calls.some((call) => + String(call[0]).includes("Already on best account 1"), + ), + ).toBe(true); + expect( + logSpy.mock.calls.some((call) => + String(call[0]).includes("Live check notes (1)"), + ), + ).toBe(true); + expect( + logSpy.mock.calls.some((call) => + String(call[0]).includes("network timeout"), + ), + ).toBe(true); }); it("reuses the queued refresh result across concurrent live best runs", async () => { @@ -2543,7 +2644,9 @@ describe("codex manager cli commands", () => { }, ], }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -2591,7 +2694,12 @@ describe("codex manager cli commands", () => { const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const firstRun = runCodexMultiAuthCli(["auth", "best", "--live", "--json"]); - const secondRun = runCodexMultiAuthCli(["auth", "best", "--live", "--json"]); + const secondRun = runCodexMultiAuthCli([ + "auth", + "best", + "--live", + "--json", + ]); refreshDeferred.resolve({ type: "success", @@ -2601,7 +2709,10 @@ describe("codex manager cli commands", () => { idToken: "id-best-next", }); - const [firstExitCode, secondExitCode] = await Promise.all([firstRun, secondRun]); + const [firstExitCode, secondExitCode] = await Promise.all([ + firstRun, + secondRun, + ]); expect(firstExitCode).toBe(0); expect(secondExitCode).toBe(0); @@ -2612,14 +2723,16 @@ describe("codex manager cli commands", () => { expect(storageState.accounts[0]?.refreshToken).toBe("refresh-best-next"); expect(setCodexCliActiveSelectionMock).toHaveBeenCalledTimes(2); for (const call of setCodexCliActiveSelectionMock.mock.calls) { - expect(call[0]).toEqual(expect.objectContaining({ - accountId: "acc_test", - email: "best@example.com", - accessToken: "access-best-next", - refreshToken: "refresh-best-next", - expiresAt: now + 3_600_000, - idToken: "id-best-next", - })); + expect(call[0]).toEqual( + expect.objectContaining({ + accountId: "acc_test", + email: "best@example.com", + accessToken: "access-best-next", + refreshToken: "refresh-best-next", + expiresAt: now + 3_600_000, + idToken: "id-best-next", + }), + ); } expect(logSpy.mock.calls).toHaveLength(2); for (const call of logSpy.mock.calls) { @@ -3022,7 +3135,9 @@ describe("codex manager cli commands", () => { activeIndexByFamily: { codex: 0 }, accounts: [] as Array>, }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -3064,12 +3179,16 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(openBrowserUrlMock).not.toHaveBeenCalled(); - expect(vi.mocked(serverModule.startLocalOAuthServer)).not.toHaveBeenCalled(); + expect( + vi.mocked(serverModule.startLocalOAuthServer), + ).not.toHaveBeenCalled(); expect(waitForCodeMock).not.toHaveBeenCalled(); - expect(renderedLogs.some((entry) => entry.includes("Manual mode active"))).toBe( - true, - ); - expect(renderedLogs.some((entry) => entry.includes("No callback received"))).toBe(false); + expect( + renderedLogs.some((entry) => entry.includes("Manual mode active")), + ).toBe(true); + expect( + renderedLogs.some((entry) => entry.includes("No callback received")), + ).toBe(false); expect(storageState.accounts).toHaveLength(1); }); @@ -3138,7 +3257,9 @@ describe("codex manager cli commands", () => { mtimeMs: now - 60_000, }, ]); - restoreAccountsFromBackupMock.mockResolvedValue(structuredClone(restoredStorage)); + restoreAccountsFromBackupMock.mockResolvedValue( + structuredClone(restoredStorage), + ); setCodexCliActiveSelectionMock.mockResolvedValueOnce(true); selectMock .mockResolvedValueOnce("restore-backup") @@ -3163,9 +3284,10 @@ describe("codex manager cli commands", () => { expect(signInItems.map((item) => item.label)).toContain( "Recover saved accounts", ); - expect(signInItems.find((item) => item.label === "Recover saved accounts")?.kind).toBe( - "heading", - ); + expect( + signInItems.find((item) => item.label === "Recover saved accounts") + ?.kind, + ).toBe("heading"); expect( signInItems.find((item) => item.label === "Restore Saved Backup")?.hint, ).toBe("last-good.json | 2 accounts | saved Localized Saved Time"); @@ -3180,7 +3302,9 @@ describe("codex manager cli commands", () => { "/mock/backups/last-good.json", { persist: false }, ); - expect(confirmMock).toHaveBeenCalledWith("Load last-good.json (2 accounts)?"); + expect(confirmMock).toHaveBeenCalledWith( + "Load last-good.json (2 accounts)?", + ); expect(saveAccountsMock).toHaveBeenCalledTimes(1); expect(saveAccountsMock.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ @@ -3280,7 +3404,9 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(restoreAccountsFromBackupMock).not.toHaveBeenCalled(); - expect(confirmMock).not.toHaveBeenCalledWith("Load manual-choice.json (1 account)?"); + expect(confirmMock).not.toHaveBeenCalledWith( + "Load manual-choice.json (1 account)?", + ); expect(saveAccountsMock).not.toHaveBeenCalled(); expect(promptLoginModeMock).not.toHaveBeenCalled(); }); @@ -3356,7 +3482,9 @@ describe("codex manager cli commands", () => { expect.any(String), ]); expect(restoreAccountsFromBackupMock).not.toHaveBeenCalled(); - expect(confirmMock).toHaveBeenCalledWith("Load replacement.json (2 accounts)?"); + expect(confirmMock).toHaveBeenCalledWith( + "Load replacement.json (2 accounts)?", + ); }); it("does not offer backup restore on onboarding when accounts already exist", async () => { @@ -3494,7 +3622,9 @@ describe("codex manager cli commands", () => { mtimeMs: now, }, ]); - restoreAccountsFromBackupMock.mockResolvedValue(structuredClone(restoredStorage)); + restoreAccountsFromBackupMock.mockResolvedValue( + structuredClone(restoredStorage), + ); setCodexCliActiveSelectionMock.mockResolvedValueOnce(false); selectMock .mockResolvedValueOnce("restore-backup") @@ -3556,7 +3686,9 @@ describe("codex manager cli commands", () => { expect(saveAccountsMock).not.toHaveBeenCalled(); expect(promptLoginModeMock).not.toHaveBeenCalled(); expect(selectMock).toHaveBeenCalledTimes(3); - expect(errorSpy).toHaveBeenCalledWith("Backup restore failed: File is busy"); + expect(errorSpy).toHaveBeenCalledWith( + "Backup restore failed: File is busy", + ); }); it("prints the storage hint only once when restore fails with StorageError", async () => { @@ -3627,8 +3759,12 @@ describe("codex manager cli commands", () => { mtimeMs: now, }, ]); - restoreAccountsFromBackupMock.mockResolvedValue(structuredClone(restoredStorage)); - saveAccountsMock.mockRejectedValueOnce(new Error("save selected account failed")); + restoreAccountsFromBackupMock.mockResolvedValue( + structuredClone(restoredStorage), + ); + saveAccountsMock.mockRejectedValueOnce( + new Error("save selected account failed"), + ); selectMock .mockResolvedValueOnce("restore-backup") .mockResolvedValueOnce("latest") @@ -3688,7 +3824,9 @@ describe("codex manager cli commands", () => { mtimeMs: now, }, ]); - restoreAccountsFromBackupMock.mockResolvedValue(structuredClone(restoredStorage)); + restoreAccountsFromBackupMock.mockResolvedValue( + structuredClone(restoredStorage), + ); selectMock .mockResolvedValueOnce("restore-backup") .mockResolvedValueOnce("latest"); @@ -3763,7 +3901,9 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(confirmMock).toHaveBeenCalledWith("Load manual-choice.json (1 account)?"); + expect(confirmMock).toHaveBeenCalledWith( + "Load manual-choice.json (1 account)?", + ); expect(restoreAccountsFromBackupMock).not.toHaveBeenCalled(); expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); }); @@ -3830,17 +3970,21 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(selectMock).toHaveBeenCalled(); expect(openBrowserUrlMock).not.toHaveBeenCalled(); - expect(vi.mocked(serverModule.startLocalOAuthServer)).not.toHaveBeenCalled(); + expect( + vi.mocked(serverModule.startLocalOAuthServer), + ).not.toHaveBeenCalled(); expect(waitForCodeMock).not.toHaveBeenCalled(); const signInItems = selectMock.mock.calls[0]?.[0] as Array<{ label: string; value?: string; }>; expect(signInItems.some((item) => item.value === "manual")).toBe(true); - expect(renderedLogs.some((entry) => entry.includes("Manual mode active"))).toBe( - true, - ); - expect(renderedLogs.some((entry) => entry.includes("No callback received"))).toBe(false); + expect( + renderedLogs.some((entry) => entry.includes("Manual mode active")), + ).toBe(true); + expect( + renderedLogs.some((entry) => entry.includes("No callback received")), + ).toBe(false); expect(logSpy).toHaveBeenCalledWith("Refreshed account 1."); }); @@ -3853,7 +3997,9 @@ describe("codex manager cli commands", () => { activeIndexByFamily: { codex: 0 }, accounts: [] as Array>, }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -3881,9 +4027,13 @@ describe("codex manager cli commands", () => { const browserModule = await import("../lib/auth/browser.js"); const openBrowserUrlMock = vi.mocked(browserModule.openBrowserUrl); - vi.mocked(browserModule.isBrowserLaunchSuppressed).mockReturnValueOnce(true); + vi.mocked(browserModule.isBrowserLaunchSuppressed).mockReturnValueOnce( + true, + ); const serverModule = await import("../lib/auth/server.js"); - const startLocalOAuthServerMock = vi.mocked(serverModule.startLocalOAuthServer); + const startLocalOAuthServerMock = vi.mocked( + serverModule.startLocalOAuthServer, + ); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); @@ -3904,7 +4054,9 @@ describe("codex manager cli commands", () => { activeIndexByFamily: { codex: 0 }, accounts: [] as Array>, }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -3938,7 +4090,9 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(promptQuestionMock).toHaveBeenCalledWith(""); expect(openBrowserUrlMock).not.toHaveBeenCalled(); - expect(vi.mocked(serverModule.startLocalOAuthServer)).not.toHaveBeenCalled(); + expect( + vi.mocked(serverModule.startLocalOAuthServer), + ).not.toHaveBeenCalled(); expect(storageState.accounts).toHaveLength(1); }); @@ -3950,7 +4104,9 @@ describe("codex manager cli commands", () => { activeIndexByFamily: { codex: 0 }, accounts: [] as Array>, }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -3965,7 +4121,9 @@ describe("codex manager cli commands", () => { state: "oauth-state", url: "https://auth.openai.com/mock", }); - const exchangeAuthorizationCodeMock = vi.mocked(authModule.exchangeAuthorizationCode); + const exchangeAuthorizationCodeMock = vi.mocked( + authModule.exchangeAuthorizationCode, + ); const browserModule = await import("../lib/auth/browser.js"); const openBrowserUrlMock = vi.mocked(browserModule.openBrowserUrl); @@ -3977,7 +4135,9 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(promptQuestionMock).toHaveBeenCalledWith(""); expect(openBrowserUrlMock).not.toHaveBeenCalled(); - expect(vi.mocked(serverModule.startLocalOAuthServer)).not.toHaveBeenCalled(); + expect( + vi.mocked(serverModule.startLocalOAuthServer), + ).not.toHaveBeenCalled(); expect(exchangeAuthorizationCodeMock).not.toHaveBeenCalled(); expect(storageState.accounts).toHaveLength(0); }); @@ -3991,7 +4151,9 @@ describe("codex manager cli commands", () => { activeIndexByFamily: { codex: 0 }, accounts: [] as Array>, }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -4025,7 +4187,9 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(promptQuestionMock).toHaveBeenCalledWith(""); expect(openBrowserUrlMock).not.toHaveBeenCalled(); - expect(vi.mocked(serverModule.startLocalOAuthServer)).not.toHaveBeenCalled(); + expect( + vi.mocked(serverModule.startLocalOAuthServer), + ).not.toHaveBeenCalled(); expect(storageState.accounts).toHaveLength(1); }); @@ -4037,7 +4201,9 @@ describe("codex manager cli commands", () => { activeIndexByFamily: { codex: 0 }, accounts: [] as Array>, }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -4050,7 +4216,9 @@ describe("codex manager cli commands", () => { state: "oauth-state", url: "https://auth.openai.com/mock", }); - const exchangeAuthorizationCodeMock = vi.mocked(authModule.exchangeAuthorizationCode); + const exchangeAuthorizationCodeMock = vi.mocked( + authModule.exchangeAuthorizationCode, + ); const browserModule = await import("../lib/auth/browser.js"); const openBrowserUrlMock = vi.mocked(browserModule.openBrowserUrl); @@ -4062,7 +4230,9 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(promptQuestionMock).toHaveBeenCalledWith(""); expect(openBrowserUrlMock).not.toHaveBeenCalled(); - expect(vi.mocked(serverModule.startLocalOAuthServer)).not.toHaveBeenCalled(); + expect( + vi.mocked(serverModule.startLocalOAuthServer), + ).not.toHaveBeenCalled(); expect(exchangeAuthorizationCodeMock).not.toHaveBeenCalled(); expect(storageState.accounts).toHaveLength(0); }); @@ -4108,7 +4278,9 @@ describe("codex manager cli commands", () => { mtimeMs: now - 60_000, }, ]); - restoreAccountsFromBackupMock.mockResolvedValue(structuredClone(restoredStorage)); + restoreAccountsFromBackupMock.mockResolvedValue( + structuredClone(restoredStorage), + ); setCodexCliActiveSelectionMock.mockResolvedValueOnce(true); selectMock .mockResolvedValueOnce("restore-backup") @@ -4130,7 +4302,9 @@ describe("codex manager cli commands", () => { "/mock/backups/manual-choice.json", { persist: false }, ); - expect(confirmMock).toHaveBeenCalledWith("Load manual-choice.json (1 account)?"); + expect(confirmMock).toHaveBeenCalledWith( + "Load manual-choice.json (1 account)?", + ); expect(saveAccountsMock).toHaveBeenCalledTimes(1); expect(setCodexCliActiveSelectionMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -4186,7 +4360,9 @@ describe("codex manager cli commands", () => { mtimeMs: now, }, ]); - restoreAccountsFromBackupMock.mockResolvedValue(structuredClone(restoredStorage)); + restoreAccountsFromBackupMock.mockResolvedValue( + structuredClone(restoredStorage), + ); setCodexCliActiveSelectionMock.mockResolvedValueOnce(true); selectMock .mockResolvedValueOnce("restore-backup") @@ -4242,9 +4418,7 @@ describe("codex manager cli commands", () => { mtimeMs: now, }, ]); - selectMock - .mockResolvedValueOnce("browser") - .mockResolvedValueOnce("cancel"); + selectMock.mockResolvedValueOnce("browser").mockResolvedValueOnce("cancel"); promptAddAnotherAccountMock.mockResolvedValueOnce(true); promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); @@ -4292,12 +4466,12 @@ describe("codex manager cli commands", () => { label: string; value?: string; }>; - expect(firstSignInItems.some((item) => item.value === "restore-backup")).toBe( - true, - ); - expect(secondSignInItems.some((item) => item.value === "restore-backup")).toBe( - false, - ); + expect( + firstSignInItems.some((item) => item.value === "restore-backup"), + ).toBe(true); + expect( + secondSignInItems.some((item) => item.value === "restore-backup"), + ).toBe(false); expect(promptLoginModeMock).toHaveBeenCalledTimes(1); }); it("preserves distinct same-email workspaces when oauth login reuses a refresh token", async () => { @@ -4908,13 +5082,11 @@ describe("codex manager cli commands", () => { }); const accountsModule = await import("../lib/accounts.js"); const extractAccountIdMock = vi.mocked(accountsModule.extractAccountId); - extractAccountIdMock.mockImplementation( - (accessToken?: string) => { - if (accessToken === "access-alpha") return "workspace-alpha"; - if (accessToken === "access-beta") return "workspace-beta"; - return "acc_test"; - }, - ); + extractAccountIdMock.mockImplementation((accessToken?: string) => { + if (accessToken === "access-alpha") return "workspace-alpha"; + if (accessToken === "access-beta") return "workspace-beta"; + return "acc_test"; + }); promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); @@ -4993,13 +5165,11 @@ describe("codex manager cli commands", () => { }); const accountsModule = await import("../lib/accounts.js"); const extractAccountIdMock = vi.mocked(accountsModule.extractAccountId); - extractAccountIdMock.mockImplementation( - (accessToken?: string) => { - if (accessToken === "access-alpha") return "workspace-alpha"; - if (accessToken === "access-beta") return "workspace-beta"; - return "acc_test"; - }, - ); + extractAccountIdMock.mockImplementation((accessToken?: string) => { + if (accessToken === "access-alpha") return "workspace-alpha"; + if (accessToken === "access-beta") return "workspace-beta"; + return "acc_test"; + }); fetchCodexQuotaSnapshotMock .mockResolvedValueOnce({ status: 200, @@ -5745,9 +5915,7 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(readSettingsHubPanelContract()).toEqual( - SETTINGS_HUB_MENU_ORDER, - ); + expect(readSettingsHubPanelContract()).toEqual(SETTINGS_HUB_MENU_ORDER); expect(selectSequence.remaining()).toBe(0); expect(saveDashboardDisplaySettingsMock).toHaveBeenCalled(); expect(savePluginConfigMock).toHaveBeenCalledTimes(1); @@ -5774,7 +5942,17 @@ describe("codex manager cli commands", () => { it("runs experimental oc sync with mandatory preview before apply", async () => { const now = Date.now(); setupInteractiveSettingsLogin(createSettingsStorage(now)); - detectOcChatgptMultiAuthTargetMock.mockReturnValue({ kind: "target", descriptor: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" } }); + detectOcChatgptMultiAuthTargetMock.mockReturnValue({ + kind: "target", + descriptor: { + scope: "global", + root: "C:/target", + accountPath: "C:/target/openai-codex-accounts.json", + backupRoot: "C:/target/backups", + source: "default-global", + resolution: "accounts", + }, + }); planOcChatgptSyncMock.mockResolvedValue({ kind: "ready", target: { @@ -5830,7 +6008,11 @@ describe("codex manager cli commands", () => { expect(applyOcChatgptSyncMock).toHaveBeenCalledOnce(); expect(selectMock).toHaveBeenCalledWith( expect.arrayContaining([ - expect.objectContaining({ label: expect.stringContaining("Active selection: preserve-destination") }), + expect.objectContaining({ + label: expect.stringContaining( + "Active selection: preserve-destination", + ), + }), ]), expect.any(Object), ); @@ -5908,10 +6090,24 @@ describe("codex manager cli commands", () => { it("shows guidance when experimental oc sync target is ambiguous or unreadable", async () => { const now = Date.now(); setupInteractiveSettingsLogin(createSettingsStorage(now)); - detectOcChatgptMultiAuthTargetMock.mockReturnValue({ kind: "target", descriptor: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" } }); + detectOcChatgptMultiAuthTargetMock.mockReturnValue({ + kind: "target", + descriptor: { + scope: "global", + root: "C:/target", + accountPath: "C:/target/openai-codex-accounts.json", + backupRoot: "C:/target/backups", + source: "default-global", + resolution: "accounts", + }, + }); planOcChatgptSyncMock.mockResolvedValue({ kind: "blocked-ambiguous", - detection: { kind: "ambiguous", reason: "multiple targets", candidates: [] }, + detection: { + kind: "ambiguous", + reason: "multiple targets", + candidates: [], + }, }); const selectSequence = queueSettingsSelectSequence([ { type: "experimental" }, @@ -5930,12 +6126,14 @@ describe("codex manager cli commands", () => { expect(applyOcChatgptSyncMock).not.toHaveBeenCalled(); }); - it("exports named pool backup from experimental settings", async () => { const now = Date.now(); setupInteractiveSettingsLogin(createSettingsStorage(now)); promptQuestionMock.mockResolvedValueOnce("backup-2026-03-10"); - runNamedBackupExportMock.mockResolvedValueOnce({ kind: "exported", path: "/mock/backups/backup-2026-03-10.json" }); + runNamedBackupExportMock.mockResolvedValueOnce({ + kind: "exported", + path: "/mock/backups/backup-2026-03-10.json", + }); const selectSequence = queueSettingsSelectSequence([ { type: "experimental" }, { type: "backup" }, @@ -5950,7 +6148,9 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(selectSequence.remaining()).toBe(0); expect(promptQuestionMock).toHaveBeenCalledOnce(); - expect(runNamedBackupExportMock).toHaveBeenCalledWith({ name: "backup-2026-03-10" }); + expect(runNamedBackupExportMock).toHaveBeenCalledWith({ + name: "backup-2026-03-10", + }); }); it("supports backup hotkeys from experimental menu through result status", async () => { @@ -5984,7 +6184,10 @@ describe("codex manager cli commands", () => { const now = Date.now(); setupInteractiveSettingsLogin(createSettingsStorage(now)); promptQuestionMock.mockResolvedValueOnce("../bad-name"); - runNamedBackupExportMock.mockResolvedValueOnce({ kind: "collision", path: "/mock/backups/bad-name.json" }); + runNamedBackupExportMock.mockResolvedValueOnce({ + kind: "collision", + path: "/mock/backups/bad-name.json", + }); const selectSequence = queueSettingsSelectSequence([ { type: "experimental" }, { type: "backup" }, @@ -5999,18 +6202,49 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(selectSequence.remaining()).toBe(0); expect(promptQuestionMock).toHaveBeenCalledOnce(); - expect(runNamedBackupExportMock).toHaveBeenCalledWith({ name: "../bad-name" }); + expect(runNamedBackupExportMock).toHaveBeenCalledWith({ + name: "../bad-name", + }); }); it("backs out of experimental sync preview without applying", async () => { const now = Date.now(); setupInteractiveSettingsLogin(createSettingsStorage(now)); - detectOcChatgptMultiAuthTargetMock.mockReturnValue({ kind: "target", descriptor: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" } }); - normalizeAccountStorageMock.mockReturnValue({ version: 3, accounts: [], activeIndex: 0 }); + detectOcChatgptMultiAuthTargetMock.mockReturnValue({ + kind: "target", + descriptor: { + scope: "global", + root: "C:/target", + accountPath: "C:/target/openai-codex-accounts.json", + backupRoot: "C:/target/backups", + source: "default-global", + resolution: "accounts", + }, + }); + normalizeAccountStorageMock.mockReturnValue({ + version: 3, + accounts: [], + activeIndex: 0, + }); planOcChatgptSyncMock.mockResolvedValue({ kind: "ready", - target: { scope: "global", root: "C:/target", accountPath: "C:/target/openai-codex-accounts.json", backupRoot: "C:/target/backups", source: "default-global", resolution: "accounts" }, - preview: { payload: { version: 3, accounts: [], activeIndex: 0 }, merged: { version: 3, accounts: [], activeIndex: 0 }, toAdd: [], toUpdate: [], toSkip: [], unchangedDestinationOnly: [], activeSelectionBehavior: "preserve-destination" }, + target: { + scope: "global", + root: "C:/target", + accountPath: "C:/target/openai-codex-accounts.json", + backupRoot: "C:/target/backups", + source: "default-global", + resolution: "accounts", + }, + preview: { + payload: { version: 3, accounts: [], activeIndex: 0 }, + merged: { version: 3, accounts: [], activeIndex: 0 }, + toAdd: [], + toUpdate: [], + toSkip: [], + unchangedDestinationOnly: [], + activeSelectionBehavior: "preserve-destination", + }, payload: { version: 3, accounts: [], activeIndex: 0 }, destination: { version: 3, accounts: [], activeIndex: 0 }, }); @@ -6078,7 +6312,11 @@ describe("codex manager cli commands", () => { }); planOcChatgptSyncMock.mockResolvedValue({ kind: "blocked-ambiguous", - detection: { kind: "ambiguous", reason: "multiple targets", candidates: [] }, + detection: { + kind: "ambiguous", + reason: "multiple targets", + candidates: [], + }, }); const selectSequence = queueSettingsSelectSequence([ { type: "experimental" }, @@ -6237,9 +6475,7 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(readSettingsHubPanelContract()).toEqual( - SETTINGS_HUB_MENU_ORDER, - ); + expect(readSettingsHubPanelContract()).toEqual(SETTINGS_HUB_MENU_ORDER); expect(selectSequence.remaining()).toBe(0); expect(saveDashboardDisplaySettingsMock).toHaveBeenCalledTimes(4); expect(saveDashboardDisplaySettingsMock.mock.calls[0]?.[0]).toEqual( @@ -6276,7 +6512,6 @@ describe("codex manager cli commands", () => { ); }); - it("moves guardian controls into experimental settings", async () => { const now = Date.now(); setupInteractiveSettingsLogin(createSettingsStorage(now)); @@ -6299,7 +6534,8 @@ describe("codex manager cli commands", () => { expect(savePluginConfigMock).toHaveBeenCalledWith( expect.objectContaining({ proactiveRefreshGuardian: !(defaults.proactiveRefreshGuardian ?? false), - proactiveRefreshIntervalMs: (defaults.proactiveRefreshIntervalMs ?? 60000) + 60000, + proactiveRefreshIntervalMs: + (defaults.proactiveRefreshIntervalMs ?? 60000) + 60000, }), ); }); @@ -6359,8 +6595,7 @@ describe("codex manager cli commands", () => { preemptiveQuotaRemainingPercent5h: (defaults.preemptiveQuotaRemainingPercent5h ?? 0) + 1, storageBackupEnabled: !(defaults.storageBackupEnabled ?? false), - tokenRefreshSkewMs: - (defaults.tokenRefreshSkewMs ?? 60_000) + 10_000, + tokenRefreshSkewMs: (defaults.tokenRefreshSkewMs ?? 60_000) + 10_000, parallelProbing: !(defaults.parallelProbing ?? false), fetchTimeoutMs: (defaults.fetchTimeoutMs ?? 60_000) + 5_000, }), @@ -7121,7 +7356,8 @@ describe("codex manager cli commands", () => { ); vi.mocked(accountsModule.extractAccountEmail).mockImplementation( (accessToken?: string) => { - if (accessToken === "access-alpha-refreshed") return "owner@example.com"; + if (accessToken === "access-alpha-refreshed") + return "owner@example.com"; return undefined; }, ); @@ -7243,21 +7479,19 @@ describe("codex manager cli commands", () => { }); const accountsModule = await import("../lib/accounts.js"); const extractAccountIdMock = vi.mocked(accountsModule.extractAccountId); - const extractAccountEmailMock = vi.mocked(accountsModule.extractAccountEmail); - extractAccountIdMock.mockImplementation( - (accessToken?: string) => { - if (accessToken === "access-alpha-stale") return "shared-workspace"; - if (accessToken === "access-alpha-refreshed") return "shared-workspace"; - if (accessToken === "access-beta") return "shared-workspace"; - return "acc_test"; - }, - ); - extractAccountEmailMock.mockImplementation( - (accessToken?: string) => { - if (accessToken === "access-alpha-refreshed") return "owner@example.com"; - return undefined; - }, + const extractAccountEmailMock = vi.mocked( + accountsModule.extractAccountEmail, ); + extractAccountIdMock.mockImplementation((accessToken?: string) => { + if (accessToken === "access-alpha-stale") return "shared-workspace"; + if (accessToken === "access-alpha-refreshed") return "shared-workspace"; + if (accessToken === "access-beta") return "shared-workspace"; + return "acc_test"; + }); + extractAccountEmailMock.mockImplementation((accessToken?: string) => { + if (accessToken === "access-alpha-refreshed") return "owner@example.com"; + return undefined; + }); fetchCodexQuotaSnapshotMock .mockResolvedValueOnce({ status: 200, @@ -7419,7 +7653,8 @@ describe("codex manager cli commands", () => { ); vi.mocked(accountsModule.extractAccountEmail).mockImplementation( (accessToken?: string) => { - if (accessToken === "access-alpha-refreshed") return "owner@example.com"; + if (accessToken === "access-alpha-refreshed") + return "owner@example.com"; return undefined; }, ); From fb40881ae61959cd510d0a5d83b99e769f290426 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 10:42:08 +0800 Subject: [PATCH 277/376] test: cover forecast explain output --- test/codex-manager-cli.test.ts | 46 ++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 9bd16887..29ccf0d5 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -691,6 +691,52 @@ describe("codex manager cli commands", () => { expect(payload.explanation.considered[0]?.selected).toBe(true); }); + it("prints explain details in text forecast mode", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + }, + { + email: "b@example.com", + refreshToken: "refresh-b", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: false, + }, + ], + }); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli([ + "auth", + "forecast", + "--explain", + ]); + expect(exitCode).toBe(0); + expect(errorSpy).not.toHaveBeenCalled(); + expect( + logSpy.mock.calls.some((call) => String(call[0]).includes("Explain:")), + ).toBe(true); + expect( + logSpy.mock.calls.some( + (call) => + String(call[0]).includes("ready, low risk (0)") || + String(call[0]).includes("Lowest risk ready account"), + ), + ).toBe(true); + }); + it("does not mutate loaded quota cache when live forecast save fails", async () => { const now = Date.now(); const originalQuotaCache = { From 07222d890954a4422260cc36d64710cf960bace3 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 10:56:37 +0800 Subject: [PATCH 278/376] docs: document forecast explain flag --- docs/reference/commands.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index c69b3065..d416564d 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -47,6 +47,7 @@ Compatibility aliases are supported: | --- | --- | --- | | `--manual`, `--no-browser` | login | Skip browser launch and use manual callback flow | | `--json` | verify-flagged, forecast, report, fix, doctor | Print machine-readable output | +| `--explain` | forecast | Include recommendation reasoning details | | `--live` | forecast, report, fix | Use live probe before decisions/output | | `--dry-run` | verify-flagged, fix, doctor | Preview without writing storage | | `--model ` | forecast, report, fix | Specify model for live probe paths | @@ -117,7 +118,7 @@ Health and planning: ```bash codex auth check -codex auth forecast --live --model gpt-5-codex +codex auth forecast --live --explain --model gpt-5-codex codex auth report --live --json ``` From 75424bc8ef886d7da45281489f5f24a125fbcebf Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 11:09:11 +0800 Subject: [PATCH 279/376] docs: add maintainer runbooks --- docs/README.md | 3 ++ .../RUNBOOK_ADD_AUTH_MANAGER_COMMAND.md | 36 +++++++++++++++++ .../RUNBOOK_ADD_CONFIG_FIELD_SAFELY.md | 39 +++++++++++++++++++ .../RUNBOOK_CHANGE_ROUTING_POLICY_SAFELY.md | 37 ++++++++++++++++++ test/documentation.test.ts | 11 ++++++ 5 files changed, 126 insertions(+) create mode 100644 docs/development/RUNBOOK_ADD_AUTH_MANAGER_COMMAND.md create mode 100644 docs/development/RUNBOOK_ADD_CONFIG_FIELD_SAFELY.md create mode 100644 docs/development/RUNBOOK_CHANGE_ROUTING_POLICY_SAFELY.md diff --git a/docs/README.md b/docs/README.md index f63480fb..7325b927 100644 --- a/docs/README.md +++ b/docs/README.md @@ -63,6 +63,9 @@ Public documentation for `codex-multi-auth`. | [development/CONFIG_FIELDS.md](development/CONFIG_FIELDS.md) | Complete field and environment inventory | | [development/CONFIG_FLOW.md](development/CONFIG_FLOW.md) | Configuration resolution flow | | [development/REPOSITORY_SCOPE.md](development/REPOSITORY_SCOPE.md) | Ownership map by repository path | +| [development/RUNBOOK_ADD_AUTH_MANAGER_COMMAND.md](development/RUNBOOK_ADD_AUTH_MANAGER_COMMAND.md) | Safe workflow for adding a new `codex auth` command | +| [development/RUNBOOK_ADD_CONFIG_FIELD_SAFELY.md](development/RUNBOOK_ADD_CONFIG_FIELD_SAFELY.md) | Safe workflow for introducing a new config/settings field | +| [development/RUNBOOK_CHANGE_ROUTING_POLICY_SAFELY.md](development/RUNBOOK_CHANGE_ROUTING_POLICY_SAFELY.md) | Safe workflow for changing routing or account-selection policy | | [development/TESTING.md](development/TESTING.md) | Validation gates and test matrix | | [development/TUI_PARITY_CHECKLIST.md](development/TUI_PARITY_CHECKLIST.md) | Dashboard UX parity checklist | | [benchmarks/code-edit-format-benchmark.md](benchmarks/code-edit-format-benchmark.md) | Benchmark methodology and outputs | diff --git a/docs/development/RUNBOOK_ADD_AUTH_MANAGER_COMMAND.md b/docs/development/RUNBOOK_ADD_AUTH_MANAGER_COMMAND.md new file mode 100644 index 00000000..ae44bc0a --- /dev/null +++ b/docs/development/RUNBOOK_ADD_AUTH_MANAGER_COMMAND.md @@ -0,0 +1,36 @@ +# Runbook: Add an Auth Manager Command + +Use this when adding a new `codex auth ...` command. + +## Goal + +Add a command without breaking the existing CLI surface, help text, JSON mode, or dashboard/menu behavior. + +## Where to Change + +- `lib/codex-manager.ts` — command parsing and dispatch +- `lib/codex-manager/` — extracted command/controller helpers when the command grows beyond trivial size +- `docs/reference/commands.md` — command reference +- `test/codex-manager-cli.test.ts` — CLI behavior coverage +- `test/documentation.test.ts` — docs parity when command text/help changes + +## Safe Workflow + +1. Add the smallest possible parsing/dispatch change in `lib/codex-manager.ts`. +2. If the command has more than one logical branch, extract a helper under `lib/codex-manager/` instead of growing the main file. +3. Keep JSON output stable and explicit if the command already has `--json`. +4. Update command help text and `docs/reference/commands.md` in the same change. +5. Add or extend `test/codex-manager-cli.test.ts` for the new path. + +## Compatibility Checks + +- Preserve canonical command shape: `codex auth ` +- Do not silently change existing help text unless docs/tests are updated too +- If adding flags, update both help text and command reference + +## QA + +- `npm run typecheck` +- `npm run lint -- lib/codex-manager.ts test/codex-manager-cli.test.ts docs/reference/commands.md test/documentation.test.ts` +- `npm run test -- test/codex-manager-cli.test.ts test/documentation.test.ts` +- Run the real command or `--help` path in Bash and inspect output diff --git a/docs/development/RUNBOOK_ADD_CONFIG_FIELD_SAFELY.md b/docs/development/RUNBOOK_ADD_CONFIG_FIELD_SAFELY.md new file mode 100644 index 00000000..595e0381 --- /dev/null +++ b/docs/development/RUNBOOK_ADD_CONFIG_FIELD_SAFELY.md @@ -0,0 +1,39 @@ +# Runbook: Add a Config Field Safely + +Use this when introducing a new `pluginConfig` or dashboard setting field. + +## Goal + +Add a field without breaking defaults, migration behavior, settings persistence, or documentation parity. + +## Where to Change + +- `lib/config.ts` — runtime config resolution/defaults +- `lib/dashboard-settings.ts` or `lib/unified-settings.ts` — persisted settings shape +- `lib/codex-manager/settings-hub.ts` and extracted settings helpers — interactive editing if user-facing +- `docs/configuration.md` — user-facing config docs +- `docs/reference/settings.md` — settings reference +- `docs/development/CONFIG_FIELDS.md` — full field inventory +- `test/config.test.ts`, `test/dashboard-settings.test.ts`, `test/unified-settings.test.ts` — behavior coverage +- `test/documentation.test.ts` — docs parity + +## Safe Workflow + +1. Define the default in the owning config/settings module first. +2. Thread it through persistence and loading paths before exposing UI controls. +3. If user-facing, add the smallest possible settings UI path after the storage/config part is correct. +4. Document the field in both user docs and maintainer inventory. +5. Add tests for defaulting, persistence, and docs parity. + +## Compatibility Checks + +- New fields must have deterministic defaults. +- Do not change existing default values in the same PR unless that is the actual feature. +- Keep docs and code aligned in the same change. + +## QA + +- `npm run typecheck` +- `npm run lint -- lib/config.ts lib/dashboard-settings.ts lib/unified-settings.ts test/config.test.ts test/dashboard-settings.test.ts test/unified-settings.test.ts test/documentation.test.ts` +- Run the targeted test files that cover the field +- If the field is user-visible, exercise the real settings path manually diff --git a/docs/development/RUNBOOK_CHANGE_ROUTING_POLICY_SAFELY.md b/docs/development/RUNBOOK_CHANGE_ROUTING_POLICY_SAFELY.md new file mode 100644 index 00000000..51a64fa1 --- /dev/null +++ b/docs/development/RUNBOOK_CHANGE_ROUTING_POLICY_SAFELY.md @@ -0,0 +1,37 @@ +# Runbook: Change Routing or Account-Selection Policy Safely + +Use this when changing account selection, quota behavior, retry/failover logic, or forecast/report reasoning. + +## Goal + +Change policy without breaking request flow, account safety, or diagnostics. + +## Where to Change + +- `index.ts` — runtime orchestration +- `lib/rotation.ts` — account selection +- `lib/forecast.ts` — readiness/risk forecasting +- `lib/request/failure-policy.ts` — retry/failover decisions +- `lib/request/rate-limit-backoff.ts` — cooldown/backoff behavior +- `lib/quota-probe.ts` / `lib/quota-cache.ts` — quota-derived decision inputs +- `test/rotation.test.ts`, `test/forecast.test.ts`, `test/failure-policy.test.ts`, `test/rate-limit-backoff.test.ts`, `test/codex-manager-cli.test.ts` — policy coverage + +## Safe Workflow + +1. Isolate the policy change from pure code motion. +2. Update the reasoning-producing surfaces (`forecast`, `report`, diagnostics) if their output semantics change. +3. Add or update focused tests before widening scope. +4. Prefer one policy change per PR. + +## Compatibility Checks + +- Do not break existing JSON contract shapes unless the contract is explicitly being revised. +- If recommendation or routing reasoning changes, update the explain/report output tests too. +- Keep live-probe behavior and storage mutations covered by tests. + +## QA + +- `npm run typecheck` +- `npm run lint -- index.ts lib/rotation.ts lib/forecast.ts lib/request/failure-policy.ts lib/request/rate-limit-backoff.ts test/rotation.test.ts test/forecast.test.ts test/failure-policy.test.ts test/rate-limit-backoff.test.ts test/codex-manager-cli.test.ts` +- Run the targeted policy tests you touched +- Execute at least one real CLI/manual QA path that demonstrates the changed reasoning or routing behavior diff --git a/test/documentation.test.ts b/test/documentation.test.ts index 1c696d36..38ce182b 100644 --- a/test/documentation.test.ts +++ b/test/documentation.test.ts @@ -267,10 +267,21 @@ describe("Documentation Integrity", () => { const configGuide = read("docs/configuration.md").toLowerCase(); const settingsRef = read("docs/reference/settings.md").toLowerCase(); const fieldInventoryPath = "docs/development/CONFIG_FIELDS.md"; + const runbooks = [ + "docs/development/RUNBOOK_ADD_AUTH_MANAGER_COMMAND.md", + "docs/development/RUNBOOK_ADD_CONFIG_FIELD_SAFELY.md", + "docs/development/RUNBOOK_CHANGE_ROUTING_POLICY_SAFELY.md", + ]; expect( existsSync(join(projectRoot, fieldInventoryPath)), `${fieldInventoryPath} should exist`, ).toBe(true); + for (const runbook of runbooks) { + expect( + existsSync(join(projectRoot, runbook)), + `${runbook} should exist`, + ).toBe(true); + } const fieldInventory = read(fieldInventoryPath).toLowerCase(); expect(configGuide).toContain("stable environment overrides"); From 52f475a3d5b21c58e6b7f31bf83263d10e6668a0 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 11:37:09 +0800 Subject: [PATCH 280/376] docs: add v1.2.0 release notes stub --- docs/releases/v1.2.0.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 docs/releases/v1.2.0.md diff --git a/docs/releases/v1.2.0.md b/docs/releases/v1.2.0.md new file mode 100644 index 00000000..95b9d235 --- /dev/null +++ b/docs/releases/v1.2.0.md @@ -0,0 +1,18 @@ +# Release v1.2.0 + +Release line: `stable` + +This document anchors the current stable release reference used by the docs portal. + +## Scope + +- Current package version in `package.json` is `1.2.0`. +- Canonical command family remains `codex auth ...`. +- Canonical package name remains `codex-multi-auth`. + +## Related + +- [../getting-started.md](../getting-started.md) +- [../upgrade.md](../upgrade.md) +- [../reference/commands.md](../reference/commands.md) +- [../reference/public-api.md](../reference/public-api.md) From 5bd773979666e714dcfa906b5ea88cbae2739db8 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 12:44:24 +0800 Subject: [PATCH 281/376] test: cover benchmark dashboard renderer --- .../benchmark-render-dashboard-script.test.ts | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 test/benchmark-render-dashboard-script.test.ts diff --git a/test/benchmark-render-dashboard-script.test.ts b/test/benchmark-render-dashboard-script.test.ts new file mode 100644 index 00000000..8b9d9209 --- /dev/null +++ b/test/benchmark-render-dashboard-script.test.ts @@ -0,0 +1,91 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const scriptPath = path.resolve( + process.cwd(), + "scripts", + "benchmark-render-dashboard.mjs", +); +const tempRoots: string[] = []; + +afterEach(() => { + while (tempRoots.length > 0) { + const root = tempRoots.pop(); + if (root) { + rmSync(root, { recursive: true, force: true }); + } + } +}); + +describe("benchmark render dashboard script", () => { + it("renders HTML from a minimal summary file", () => { + const root = mkdtempSync(path.join(tmpdir(), "bench-render-")); + tempRoots.push(root); + const inputPath = path.join(root, "summary.json"); + const outputPath = path.join(root, "dashboard.html"); + + writeFileSync( + inputPath, + JSON.stringify( + { + meta: { + generatedAt: "2026-03-22T00:00:00.000Z", + preset: "codex-core", + models: ["gpt-5-codex"], + tasks: ["task-1"], + modes: ["patch", "replace", "hashline", "hashline_v2"], + runCount: 1, + warmupCount: 0, + }, + rows: [ + { + modelId: "gpt-5-codex", + displayName: "GPT-5 Codex", + modes: { + patch: { + accuracyPct: 90, + wallMsP50: 1000, + tokensTotalP50: 100, + }, + replace: { + accuracyPct: 85, + wallMsP50: 1100, + tokensTotalP50: 90, + }, + hashline: { + accuracyPct: 88, + wallMsP50: 1050, + tokensTotalP50: 95, + }, + hashline_v2: { + accuracyPct: 92, + wallMsP50: 980, + tokensTotalP50: 80, + }, + }, + }, + ], + failures: [], + }, + null, + 2, + ), + "utf8", + ); + + const result = spawnSync( + process.execPath, + [scriptPath, `--input=${inputPath}`, `--output=${outputPath}`], + { encoding: "utf8" }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("Dashboard written:"); + const html = readFileSync(outputPath, "utf8"); + expect(html).toContain("Code Edit Format Benchmark"); + expect(html).toContain("GPT-5 Codex"); + }); +}); From 18b69115c10f1e496e3d947a34da329cdfdef592 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:03:01 +0800 Subject: [PATCH 282/376] docs: tighten maintainer runbooks --- .../RUNBOOK_ADD_AUTH_MANAGER_COMMAND.md | 1 + .../RUNBOOK_ADD_CONFIG_FIELD_SAFELY.md | 2 ++ .../RUNBOOK_CHANGE_ROUTING_POLICY_SAFELY.md | 3 ++- test/documentation.test.ts | 20 +++++++++++-------- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/docs/development/RUNBOOK_ADD_AUTH_MANAGER_COMMAND.md b/docs/development/RUNBOOK_ADD_AUTH_MANAGER_COMMAND.md index ae44bc0a..2740fc20 100644 --- a/docs/development/RUNBOOK_ADD_AUTH_MANAGER_COMMAND.md +++ b/docs/development/RUNBOOK_ADD_AUTH_MANAGER_COMMAND.md @@ -10,6 +10,7 @@ Add a command without breaking the existing CLI surface, help text, JSON mode, o - `lib/codex-manager.ts` — command parsing and dispatch - `lib/codex-manager/` — extracted command/controller helpers when the command grows beyond trivial size +- `lib/cli.ts` — prompt-heavy shared CLI helpers when the command needs reusable interactive flows - `docs/reference/commands.md` — command reference - `test/codex-manager-cli.test.ts` — CLI behavior coverage - `test/documentation.test.ts` — docs parity when command text/help changes diff --git a/docs/development/RUNBOOK_ADD_CONFIG_FIELD_SAFELY.md b/docs/development/RUNBOOK_ADD_CONFIG_FIELD_SAFELY.md index 595e0381..a8748f5a 100644 --- a/docs/development/RUNBOOK_ADD_CONFIG_FIELD_SAFELY.md +++ b/docs/development/RUNBOOK_ADD_CONFIG_FIELD_SAFELY.md @@ -9,6 +9,7 @@ Add a field without breaking defaults, migration behavior, settings persistence, ## Where to Change - `lib/config.ts` — runtime config resolution/defaults +- `lib/schemas.ts` — env/config schema validation and typed field contracts - `lib/dashboard-settings.ts` or `lib/unified-settings.ts` — persisted settings shape - `lib/codex-manager/settings-hub.ts` and extracted settings helpers — interactive editing if user-facing - `docs/configuration.md` — user-facing config docs @@ -30,6 +31,7 @@ Add a field without breaking defaults, migration behavior, settings persistence, - New fields must have deterministic defaults. - Do not change existing default values in the same PR unless that is the actual feature. - Keep docs and code aligned in the same change. +- Recheck the Windows persistence notes when the field is written to disk; `EBUSY` and `EPERM` retry behavior must stay documented and covered. ## QA diff --git a/docs/development/RUNBOOK_CHANGE_ROUTING_POLICY_SAFELY.md b/docs/development/RUNBOOK_CHANGE_ROUTING_POLICY_SAFELY.md index 51a64fa1..157a17f8 100644 --- a/docs/development/RUNBOOK_CHANGE_ROUTING_POLICY_SAFELY.md +++ b/docs/development/RUNBOOK_CHANGE_ROUTING_POLICY_SAFELY.md @@ -9,12 +9,13 @@ Change policy without breaking request flow, account safety, or diagnostics. ## Where to Change - `index.ts` — runtime orchestration +- `lib/accounts.ts` — account selection inputs, health state, and cooldown readiness data - `lib/rotation.ts` — account selection - `lib/forecast.ts` — readiness/risk forecasting - `lib/request/failure-policy.ts` — retry/failover decisions - `lib/request/rate-limit-backoff.ts` — cooldown/backoff behavior - `lib/quota-probe.ts` / `lib/quota-cache.ts` — quota-derived decision inputs -- `test/rotation.test.ts`, `test/forecast.test.ts`, `test/failure-policy.test.ts`, `test/rate-limit-backoff.test.ts`, `test/codex-manager-cli.test.ts` — policy coverage +- `test/accounts.test.ts`, `test/rotation.test.ts`, `test/forecast.test.ts`, `test/failure-policy.test.ts`, `test/rate-limit-backoff.test.ts`, `test/codex-manager-cli.test.ts` — policy coverage ## Safe Workflow diff --git a/test/documentation.test.ts b/test/documentation.test.ts index 38ce182b..f4a01cc0 100644 --- a/test/documentation.test.ts +++ b/test/documentation.test.ts @@ -263,25 +263,29 @@ describe("Documentation Integrity", () => { expect(manager).not.toContain("codex-multi-auth auth switch "); }); - it("documents stable overrides separately from advanced and internal overrides", () => { - const configGuide = read("docs/configuration.md").toLowerCase(); - const settingsRef = read("docs/reference/settings.md").toLowerCase(); - const fieldInventoryPath = "docs/development/CONFIG_FIELDS.md"; + it("keeps maintainer runbooks present", () => { const runbooks = [ "docs/development/RUNBOOK_ADD_AUTH_MANAGER_COMMAND.md", "docs/development/RUNBOOK_ADD_CONFIG_FIELD_SAFELY.md", "docs/development/RUNBOOK_CHANGE_ROUTING_POLICY_SAFELY.md", ]; - expect( - existsSync(join(projectRoot, fieldInventoryPath)), - `${fieldInventoryPath} should exist`, - ).toBe(true); + for (const runbook of runbooks) { expect( existsSync(join(projectRoot, runbook)), `${runbook} should exist`, ).toBe(true); } + }); + + it("documents stable overrides separately from advanced and internal overrides", () => { + const configGuide = read("docs/configuration.md").toLowerCase(); + const settingsRef = read("docs/reference/settings.md").toLowerCase(); + const fieldInventoryPath = "docs/development/CONFIG_FIELDS.md"; + expect( + existsSync(join(projectRoot, fieldInventoryPath)), + `${fieldInventoryPath} should exist`, + ).toBe(true); const fieldInventory = read(fieldInventoryPath).toLowerCase(); expect(configGuide).toContain("stable environment overrides"); From 7ec5b1cfa0aef47289695fd5558e8aaacf9cd1c6 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:03:01 +0800 Subject: [PATCH 283/376] fix: keep forecast explain output visible --- docs/reference/commands.md | 3 +- lib/codex-manager.ts | 26 +++---- test/codex-manager-cli.test.ts | 123 ++++++++++++++++++++++++++++++++- 3 files changed, 138 insertions(+), 14 deletions(-) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index d416564d..5b1c8100 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -47,7 +47,7 @@ Compatibility aliases are supported: | --- | --- | --- | | `--manual`, `--no-browser` | login | Skip browser launch and use manual callback flow | | `--json` | verify-flagged, forecast, report, fix, doctor | Print machine-readable output | -| `--explain` | forecast | Include recommendation reasoning details | +| `--explain` | forecast | Include recommendation reasoning details in text and JSON output, even when recommendation summary lines are hidden | | `--live` | forecast, report, fix | Use live probe before decisions/output | | `--dry-run` | verify-flagged, fix, doctor | Preview without writing storage | | `--model ` | forecast, report, fix | Specify model for live probe paths | @@ -63,6 +63,7 @@ Compatibility aliases are supported: - `codex auth login --manual` and `codex auth login --no-browser` force the manual callback flow instead of launching a browser. - `CODEX_AUTH_NO_BROWSER=1` suppresses browser launch for automation/headless sessions. False-like values such as `0` and `false` do not disable browser launch by themselves. - In non-TTY/manual shells, pass the full redirect URL on stdin, for example: `echo "http://127.0.0.1:1455/auth/callback?code=..." | codex auth login --manual`. +- `codex auth forecast --explain` now keeps the explain breakdown visible in text mode even when dashboard settings hide recommendation summary lines. Pair it with `--json` for machine-readable reasoning snapshots. - No new npm scripts or storage migration steps were introduced for this auth-flow update. --- diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index bf00c254..3fd15fca 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -2742,7 +2742,7 @@ async function runForecast(args: string[]): Promise { return 1; } const options = parsedArgs.options; - const display = DEFAULT_DASHBOARD_DISPLAY_SETTINGS; + const display = await loadDashboardDisplaySettings(); const quotaCache = options.live ? await loadQuotaCache() : null; const workingQuotaCache = quotaCache ? cloneQuotaCacheData(quotaCache) : null; let quotaCacheChanged = false; @@ -2939,8 +2939,11 @@ async function runForecast(args: string[]): Promise { ); } - if (display.showRecommendations) { + if (display.showRecommendations || options.explain) { console.log(""); + } + + if (display.showRecommendations) { if (recommendation.recommendedIndex !== null) { const index = recommendation.recommendedIndex; const account = forecastResults.find((result) => result.index === index); @@ -2962,16 +2965,15 @@ async function runForecast(args: string[]): Promise { `${stylePromptText("Note:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`, ); } - if (options.explain) { - console.log(""); - console.log(stylePromptText("Explain:", "accent")); - for (const item of explanation.considered) { - const prefix = item.selected ? "*" : "-"; - const reasons = item.reasons.slice(0, 3).join("; "); - console.log( - `${stylePromptText(prefix, item.selected ? "success" : "muted")} ${stylePromptText(`${item.index + 1}. ${item.label}`, item.selected ? "success" : "accent")} ${stylePromptText("|", "muted")} ${stylePromptText(`${item.availability}, ${item.riskLevel} risk (${item.riskScore})`, item.selected ? "success" : "muted")}${reasons ? ` ${stylePromptText("|", "muted")} ${stylePromptText(reasons, "muted")}` : ""}`, - ); - } + } + if (options.explain) { + console.log(stylePromptText("Explain:", "accent")); + for (const item of explanation.considered) { + const prefix = item.selected ? "*" : "-"; + const reasons = item.reasons.slice(0, 3).join("; "); + console.log( + `${stylePromptText(prefix, item.selected ? "success" : "muted")} ${stylePromptText(`${item.index + 1}. ${item.label}`, item.selected ? "success" : "accent")} ${stylePromptText("|", "muted")} ${stylePromptText(`${item.availability}, ${item.riskLevel} risk (${item.riskScore})`, item.selected ? "success" : "muted")}${reasons ? ` ${stylePromptText("|", "muted")} ${stylePromptText(reasons, "muted")}` : ""}`, + ); } } diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 29ccf0d5..5e674232 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -634,10 +634,12 @@ describe("codex manager cli commands", () => { command: string; summary: { total: number }; recommendation: { recommendedIndex: number | null }; + explanation?: unknown; }; expect(payload.command).toBe("forecast"); expect(payload.summary.total).toBe(2); expect(payload.recommendation.recommendedIndex).toBe(0); + expect(payload.explanation).toBeUndefined(); }); it("runs forecast in json explain mode", async () => { @@ -688,7 +690,13 @@ describe("codex manager cli commands", () => { }; expect(payload.explanation.recommendedIndex).toBe(0); expect(payload.explanation.considered).toHaveLength(2); - expect(payload.explanation.considered[0]?.selected).toBe(true); + expect(payload.explanation.considered.map((item) => item.selected)).toEqual([ + true, + false, + ]); + expect( + payload.explanation.considered.find((item) => item.selected)?.index, + ).toBe(payload.explanation.recommendedIndex); }); it("prints explain details in text forecast mode", async () => { @@ -737,6 +745,119 @@ describe("codex manager cli commands", () => { ).toBe(true); }); + it("prints explain details even when recommendation summaries are hidden", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + }, + { + email: "b@example.com", + refreshToken: "refresh-b", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: false, + }, + ], + }); + loadDashboardDisplaySettingsMock.mockResolvedValue({ + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: false, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli([ + "auth", + "forecast", + "--explain", + ]); + expect(exitCode).toBe(0); + expect(errorSpy).not.toHaveBeenCalled(); + expect( + logSpy.mock.calls.some((call) => String(call[0]).includes("Explain:")), + ).toBe(true); + expect( + logSpy.mock.calls.some((call) => String(call[0]).includes("Best next account:")), + ).toBe(false); + }); + + it("keeps forecast json explain output isolated across concurrent runs", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + }, + { + email: "b@example.com", + refreshToken: "refresh-b", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: false, + }, + ], + }); + + const outputs: string[] = []; + const logSpy = vi + .spyOn(console, "log") + .mockImplementation((value?: unknown) => outputs.push(String(value))); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const [plainExitCode, explainExitCode] = await Promise.all([ + runCodexMultiAuthCli(["auth", "forecast", "--json"]), + runCodexMultiAuthCli(["auth", "forecast", "--json", "--explain"]), + ]); + expect(plainExitCode).toBe(0); + expect(explainExitCode).toBe(0); + expect(errorSpy).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledTimes(2); + + const payloads = outputs.map((entry) => + JSON.parse(entry), + ) as Array<{ + explanation?: { + recommendedIndex: number | null; + considered: Array<{ selected: boolean }>; + }; + recommendation?: { recommendedIndex: number | null }; + }>; + expect(payloads.filter((payload) => payload.explanation)).toHaveLength(1); + const explainPayload = payloads.find((payload) => payload.explanation); + const plainPayload = payloads.find((payload) => !payload.explanation); + expect(plainPayload?.recommendation?.recommendedIndex).toBe(0); + expect(plainPayload?.explanation).toBeUndefined(); + expect(explainPayload?.explanation?.recommendedIndex).toBe(0); + expect( + explainPayload?.explanation?.considered.some((item) => item.selected), + ).toBe(true); + }); + it("does not mutate loaded quota cache when live forecast save fails", async () => { const now = Date.now(); const originalQuotaCache = { From 8dfc2ff9b5003946aad505490d3512825d756f84 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:07:16 +0800 Subject: [PATCH 284/376] test: validate shipped config templates --- test/config-schema-templates.test.ts | 118 +++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 test/config-schema-templates.test.ts diff --git a/test/config-schema-templates.test.ts b/test/config-schema-templates.test.ts new file mode 100644 index 00000000..daaf485d --- /dev/null +++ b/test/config-schema-templates.test.ts @@ -0,0 +1,118 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +type JsonSchema = { + type?: string; + properties?: Record; + required?: string[]; + items?: JsonSchema; + additionalProperties?: boolean; +}; + +const projectRoot = process.cwd(); + +function readJson(relativePath: string): unknown { + return JSON.parse( + readFileSync(path.join(projectRoot, relativePath), "utf8"), + ) as unknown; +} + +function validateAgainstSchema( + value: unknown, + schema: JsonSchema, + pathLabel = "$", +): string[] { + const errors: string[] = []; + + if (schema.type === "object") { + if (!value || typeof value !== "object" || Array.isArray(value)) { + errors.push(`${pathLabel} must be an object`); + return errors; + } + + const record = value as Record; + for (const key of schema.required ?? []) { + if (!(key in record)) { + errors.push(`${pathLabel}.${key} is required`); + } + } + + for (const [key, propertySchema] of Object.entries( + schema.properties ?? {}, + )) { + if (!(key in record)) continue; + errors.push( + ...validateAgainstSchema( + record[key], + propertySchema, + `${pathLabel}.${key}`, + ), + ); + } + return errors; + } + + if (schema.type === "array") { + if (!Array.isArray(value)) { + errors.push(`${pathLabel} must be an array`); + return errors; + } + if (schema.items) { + value.forEach((item, index) => { + errors.push( + ...validateAgainstSchema( + item, + schema.items as JsonSchema, + `${pathLabel}[${index}]`, + ), + ); + }); + } + return errors; + } + + if (schema.type === "string") { + if (typeof value !== "string") { + errors.push(`${pathLabel} must be a string`); + } + return errors; + } + + return errors; +} + +describe("config schema templates", () => { + const schema = readJson("config/schema/config.schema.json") as JsonSchema; + + it("validates shipped config templates against the schema", () => { + for (const file of [ + "config/codex-modern.json", + "config/codex-legacy.json", + "config/minimal-codex.json", + ]) { + const payload = readJson(file); + expect(validateAgainstSchema(payload, schema), file).toEqual([]); + } + }); + + it("rejects a config missing required root fields", () => { + const invalid = { + plugin: ["codex-multi-auth"], + }; + expect(validateAgainstSchema(invalid, schema)).toContain( + "$.provider is required", + ); + }); + + it("rejects wrong primitive types for required fields", () => { + const invalid = { + plugin: [123], + provider: "openai", + }; + expect(validateAgainstSchema(invalid, schema)).toEqual([ + "$.plugin[0] must be a string", + "$.provider must be an object", + ]); + }); +}); From 5c5e80df48e9203c7e21a7ae12b902837944fc52 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:08:00 +0800 Subject: [PATCH 285/376] test: harden benchmark dashboard script coverage --- .../benchmark-render-dashboard-script.test.ts | 231 +++++++++++++----- 1 file changed, 172 insertions(+), 59 deletions(-) diff --git a/test/benchmark-render-dashboard-script.test.ts b/test/benchmark-render-dashboard-script.test.ts index 8b9d9209..19e1412a 100644 --- a/test/benchmark-render-dashboard-script.test.ts +++ b/test/benchmark-render-dashboard-script.test.ts @@ -1,5 +1,6 @@ import { spawnSync } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -11,81 +12,193 @@ const scriptPath = path.resolve( ); const tempRoots: string[] = []; -afterEach(() => { +async function removeWithRetry( + targetPath: string, + options: { recursive?: boolean; force?: boolean }, +): Promise { + const retryableCodes = new Set(["ENOTEMPTY", "EPERM", "EBUSY"]); + const maxAttempts = 6; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + await rm(targetPath, options); + return; + } catch (error) { + const code = + error && + typeof error === "object" && + "code" in error && + typeof error.code === "string" + ? error.code + : undefined; + if (!code || !retryableCodes.has(code) || attempt === maxAttempts) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, attempt * 50)); + } + } +} + +function createSummaryFixture() { + return { + meta: { + generatedAt: "2026-03-22T00:00:00.000Z", + preset: "codex-core", + models: ["gpt-5-codex"], + tasks: ["task-1"], + modes: ["patch", "replace", "hashline", "hashline_v2"], + runCount: 1, + warmupCount: 0, + }, + rows: [ + { + modelId: "gpt-5-codex", + displayName: "GPT-5 Codex", + modes: { + patch: { + accuracyPct: 90, + wallMsP50: 1000, + tokensTotalP50: 100, + }, + replace: { + accuracyPct: 85, + wallMsP50: 1100, + tokensTotalP50: 90, + }, + hashline: { + accuracyPct: 88, + wallMsP50: 1050, + tokensTotalP50: 95, + }, + hashline_v2: { + accuracyPct: 92, + wallMsP50: 980, + tokensTotalP50: 80, + }, + }, + }, + ], + failures: [], + }; +} + +function createTempRoot(suffix = ""): string { + const root = mkdtempSync(path.join(tmpdir(), `bench-render${suffix}-`)); + tempRoots.push(root); + return root; +} + +function writeSummary(inputPath: string): void { + writeFileSync(inputPath, JSON.stringify(createSummaryFixture(), null, 2), "utf8"); +} + +function runRenderDashboard(args: string[]) { + return spawnSync(process.execPath, [scriptPath, ...args], { + encoding: "utf8", + timeout: 10_000, + }); +} + +afterEach(async () => { while (tempRoots.length > 0) { const root = tempRoots.pop(); if (root) { - rmSync(root, { recursive: true, force: true }); + await removeWithRetry(root, { recursive: true, force: true }); } } }); describe("benchmark render dashboard script", () => { it("renders HTML from a minimal summary file", () => { - const root = mkdtempSync(path.join(tmpdir(), "bench-render-")); - tempRoots.push(root); + const root = createTempRoot(); const inputPath = path.join(root, "summary.json"); const outputPath = path.join(root, "dashboard.html"); - writeFileSync( - inputPath, - JSON.stringify( - { - meta: { - generatedAt: "2026-03-22T00:00:00.000Z", - preset: "codex-core", - models: ["gpt-5-codex"], - tasks: ["task-1"], - modes: ["patch", "replace", "hashline", "hashline_v2"], - runCount: 1, - warmupCount: 0, - }, - rows: [ - { - modelId: "gpt-5-codex", - displayName: "GPT-5 Codex", - modes: { - patch: { - accuracyPct: 90, - wallMsP50: 1000, - tokensTotalP50: 100, - }, - replace: { - accuracyPct: 85, - wallMsP50: 1100, - tokensTotalP50: 90, - }, - hashline: { - accuracyPct: 88, - wallMsP50: 1050, - tokensTotalP50: 95, - }, - hashline_v2: { - accuracyPct: 92, - wallMsP50: 980, - tokensTotalP50: 80, - }, - }, - }, - ], - failures: [], - }, - null, - 2, - ), - "utf8", - ); - - const result = spawnSync( - process.execPath, - [scriptPath, `--input=${inputPath}`, `--output=${outputPath}`], - { encoding: "utf8" }, - ); + writeSummary(inputPath); + const result = runRenderDashboard([ + `--input=${inputPath}`, + `--output=${outputPath}`, + ]); + + expect(result.error).toBeUndefined(); expect(result.status).toBe(0); + expect(result.stderr).toBe(""); expect(result.stdout).toContain("Dashboard written:"); const html = readFileSync(outputPath, "utf8"); expect(html).toContain("Code Edit Format Benchmark"); expect(html).toContain("GPT-5 Codex"); }); + + it("renders HTML when input and output paths contain spaces", () => { + const root = createTempRoot(" spaces"); + const spacedDir = path.join(root, "with spaces"); + mkdirSync(spacedDir, { recursive: true }); + const inputPath = path.join(spacedDir, "summary file.json"); + const outputPath = path.join(spacedDir, "dashboard output.html"); + + writeSummary(inputPath); + + const result = runRenderDashboard([ + `--input=${inputPath}`, + `--output=${outputPath}`, + ]); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("Dashboard written:"); + expect(readFileSync(outputPath, "utf8")).toContain("GPT-5 Codex"); + }); + + it("fails with stderr when the input file is missing", () => { + const root = createTempRoot(); + const inputPath = path.join(root, "missing-summary.json"); + const outputPath = path.join(root, "dashboard.html"); + + const result = runRenderDashboard([ + `--input=${inputPath}`, + `--output=${outputPath}`, + ]); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Render failed:"); + expect(result.stderr.toLowerCase()).toContain("no such file"); + expect(result.stdout).not.toContain("Dashboard written:"); + }); + + it("fails with stderr when the summary json is malformed", () => { + const root = createTempRoot(); + const inputPath = path.join(root, "summary.json"); + const outputPath = path.join(root, "dashboard.html"); + + writeFileSync(inputPath, "{ not-valid-json", "utf8"); + + const result = runRenderDashboard([ + `--input=${inputPath}`, + `--output=${outputPath}`, + ]); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Render failed:"); + expect(result.stderr.toLowerCase()).toContain("json"); + expect(result.stdout).not.toContain("Dashboard written:"); + }); + + it("fails with stderr when the output directory does not exist", () => { + const root = createTempRoot(); + const inputPath = path.join(root, "summary.json"); + const outputPath = path.join(root, "missing", "dashboard.html"); + + writeSummary(inputPath); + + const result = runRenderDashboard([ + `--input=${inputPath}`, + `--output=${outputPath}`, + ]); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Render failed:"); + expect(result.stdout).not.toContain("Dashboard written:"); + }); }); From da7aebafeca5c3d64e568c5b34f1d27f5bb3613d Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:11:56 +0800 Subject: [PATCH 286/376] test: cover runtime benchmark script --- test/benchmark-runtime-path-script.test.ts | 94 ++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 test/benchmark-runtime-path-script.test.ts diff --git a/test/benchmark-runtime-path-script.test.ts b/test/benchmark-runtime-path-script.test.ts new file mode 100644 index 00000000..5e215139 --- /dev/null +++ b/test/benchmark-runtime-path-script.test.ts @@ -0,0 +1,94 @@ +import { spawnSync } from "node:child_process"; +import { + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const tempRoots: string[] = []; +const scriptPath = "scripts/benchmark-runtime-path.mjs"; + +afterEach(() => { + while (tempRoots.length > 0) { + const root = tempRoots.pop(); + if (root) rmSync(root, { recursive: true, force: true }); + } +}); + +function createRuntimeBenchmarkFixture(): { + fixtureRoot: string; + scriptCopy: string; +} { + const fixtureRoot = mkdtempSync(join(tmpdir(), "runtime-bench-fixture-")); + tempRoots.push(fixtureRoot); + + const scriptsDir = join(fixtureRoot, "scripts"); + const distRequestDir = join(fixtureRoot, "dist", "lib", "request"); + const distHelpersDir = join(distRequestDir, "helpers"); + const distLibDir = join(fixtureRoot, "dist", "lib"); + + mkdirSync(scriptsDir, { recursive: true }); + mkdirSync(distHelpersDir, { recursive: true }); + mkdirSync(distLibDir, { recursive: true }); + + const scriptCopy = join(scriptsDir, "benchmark-runtime-path.mjs"); + copyFileSync(join(process.cwd(), scriptPath), scriptCopy); + + writeFileSync( + join(distRequestDir, "request-transformer.js"), + "export function filterInput(input) { return Array.isArray(input) ? input : []; }\n", + "utf8", + ); + writeFileSync( + join(distHelpersDir, "tool-utils.js"), + "export function cleanupToolDefinitions(tools) { return Array.isArray(tools) ? tools : []; }\n", + "utf8", + ); + writeFileSync( + join(distLibDir, "accounts.js"), + [ + "export class AccountManager {", + " constructor(_, storage) { this.storage = storage; }", + " getCurrentOrNextForFamilyHybrid() { return this.storage.accounts[0] ?? null; }", + "}", + ].join("\n"), + "utf8", + ); + + return { fixtureRoot, scriptCopy }; +} + +describe("benchmark runtime path script", () => { + it("writes a benchmark payload with the expected result entries", () => { + const { fixtureRoot, scriptCopy } = createRuntimeBenchmarkFixture(); + const outputPath = join(fixtureRoot, "runtime-benchmark.json"); + + const result = spawnSync( + process.execPath, + [scriptCopy, "--iterations=1", `--output=${outputPath}`], + { encoding: "utf8" }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("Runtime benchmark written:"); + + const payload = JSON.parse(readFileSync(outputPath, "utf8")) as { + iterations: number; + results: Array<{ name: string }>; + }; + expect(payload.iterations).toBe(1); + expect(payload.results.map((entry) => entry.name)).toEqual([ + "filterInput_small", + "filterInput_large", + "cleanupToolDefinitions_medium", + "cleanupToolDefinitions_large", + "accountHybridSelection_200", + ]); + }); +}); From ba55887bd32f1c308c7f954101ea9cea1754430a Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:22:18 +0800 Subject: [PATCH 287/376] docs: refresh settings reference --- docs/reference/settings.md | 136 ++++++++++++++++++++++--------------- 1 file changed, 80 insertions(+), 56 deletions(-) diff --git a/docs/reference/settings.md b/docs/reference/settings.md index ff8900a0..035a3b32 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -1,6 +1,6 @@ # Settings Reference -Reference for dashboard and backend settings available from `codex auth login` -> `Settings`. +Reference for dashboard display settings and backend `pluginConfig` values available from `codex auth login` -> `Settings`. --- @@ -21,7 +21,7 @@ When `CODEX_MULTI_AUTH_DIR` is set, this root moves accordingly. ## Account List View -Controls account-row display and sorting behavior: +Controls account-row display and sort behavior. - `menuShowStatusBadge` - `menuShowCurrentBadge` @@ -37,44 +37,68 @@ Controls account-row display and sorting behavior: - `menuSortQuickSwitchVisibleRow` - `menuLayoutMode` +| Key | Default | Effect | +| --- | --- | --- | +| `menuShowStatusBadge` | `true` | Show ready/cooldown/disabled status badges on account rows | +| `menuShowCurrentBadge` | `true` | Mark the current account row | +| `menuShowLastUsed` | `true` | Include last-used text in row details | +| `menuShowQuotaSummary` | `true` | Show compact quota usage summaries | +| `menuShowQuotaCooldown` | `true` | Show quota reset/cooldown details | +| `menuShowFetchStatus` | `true` | Show quota fetch/probe status text | +| `menuShowDetailsForUnselectedRows` | `false` | Expand details for unselected rows | +| `menuHighlightCurrentRow` | `true` | Emphasize the current account row | +| `menuSortEnabled` | `true` | Enable menu sorting | +| `menuSortMode` | `ready-first` | Sort rows by readiness/risk heuristic | +| `menuSortPinCurrent` | `false` | Keep the current account pinned while sorting | +| `menuSortQuickSwitchVisibleRow` | `true` | Keep quick-switch numbering aligned to visible sorted rows | +| `menuLayoutMode` | `compact-details` | Choose compact or expanded row layout | + ## Summary Line -Controls detail-line fields and order: +Controls the fields shown in the per-account summary line. - `menuStatuslineFields` - `last-used` - `limits` - `status` +| Key | Default | Effect | +| --- | --- | --- | +| `menuStatuslineFields` | `last-used, limits, status` | Controls which summary fields appear and in what order | + ## Menu Behavior -Controls result-screen and fetch behavior: +Controls result-screen return behavior and menu quota refresh behavior. -- `actionAutoReturnMs` -- `actionPauseOnKey` -- `menuAutoFetchLimits` -- `menuShowFetchStatus` -- `menuQuotaTtlMs` +| Key | Default | Effect | +| --- | --- | --- | +| `actionAutoReturnMs` | `2000` | Delay before returning from action/result screens | +| `actionPauseOnKey` | `true` | Pause on keypress before auto-return completes | +| `menuAutoFetchLimits` | `true` | Refresh quota snapshots automatically in the menu | +| `menuShowFetchStatus` | `true` | Show fetch status text while quota refresh is running | +| `menuQuotaTtlMs` | `300000` | Reuse cached quota data before refetching | ## Color Theme -Controls display style: +Controls display style. -- `uiThemePreset` -- `uiAccentColor` -- `menuFocusStyle` +| Key | Default | Effect | +| --- | --- | --- | +| `uiThemePreset` | `green` | Overall theme preset | +| `uiAccentColor` | `green` | Accent color for TUI elements | +| `menuFocusStyle` | `row-invert` | Focus/highlight style in selection menus | --- ## Experimental -Experimental currently hosts: +Experimental settings currently cover: -- one-way sync preview and apply into `oc-chatgpt-multi-auth` +- one-way sync preview/apply into `oc-chatgpt-multi-auth` - named local pool backup export with filename prompt - refresh guard controls (`proactiveRefreshGuardian`, `proactiveRefreshIntervalMs`) -Experimental TUI shortcuts: +Experimental shortcuts: - `1` sync preview - `2` named backup export @@ -103,53 +127,55 @@ Named backup behavior: ### Session & Sync -Examples: - -- `liveAccountSync` -- `liveAccountSyncDebounceMs` -- `liveAccountSyncPollMs` -- `sessionAffinity` -- `sessionAffinityTtlMs` -- `sessionAffinityMaxEntries` -- `perProjectAccounts` +| Key | Default | Effect | +| --- | --- | --- | +| `liveAccountSync` | `true` | Watch account storage for external changes | +| `liveAccountSyncDebounceMs` | `250` | Debounce live-sync reloads | +| `liveAccountSyncPollMs` | `2000` | Poll interval for live-sync fallback | +| `sessionAffinity` | `true` | Keep sessions sticky to a recent account | +| `sessionAffinityTtlMs` | `1200000` | Session affinity retention window | +| `sessionAffinityMaxEntries` | `512` | Maximum affinity cache entries | +| `perProjectAccounts` | `true` | Scope account pools per project when CLI sync is off | ### Rotation & Quota -Examples: - -- `preemptiveQuotaEnabled` -- `preemptiveQuotaRemainingPercent5h` -- `preemptiveQuotaRemainingPercent7d` -- `preemptiveQuotaMaxDeferralMs` -- `retryAllAccountsRateLimited` -- `retryAllAccountsMaxWaitMs` -- `retryAllAccountsMaxRetries` +| Key | Default | Effect | +| --- | --- | --- | +| `preemptiveQuotaEnabled` | `true` | Defer requests before remaining quota is critically low | +| `preemptiveQuotaRemainingPercent5h` | `5` | 5-hour quota threshold | +| `preemptiveQuotaRemainingPercent7d` | `5` | 7-day quota threshold | +| `preemptiveQuotaMaxDeferralMs` | `7200000` | Maximum quota-based deferral window | +| `retryAllAccountsRateLimited` | `true` | Retry across the whole pool when all accounts are rate-limited | +| `retryAllAccountsMaxWaitMs` | `0` | Maximum wait budget for all-accounts-rate-limited retries | +| `retryAllAccountsMaxRetries` | `Infinity` | Maximum retry attempts for all-accounts-rate-limited loops | ### Refresh & Recovery -Examples: - -- `tokenRefreshSkewMs` -- `proactiveRefreshBufferMs` -- `storageBackupEnabled` -- `sessionRecovery` -- `autoResume` +| Key | Default | Effect | +| --- | --- | --- | +| `tokenRefreshSkewMs` | `60000` | Refresh tokens before expiry | +| `proactiveRefreshGuardian` | `true` | Run background proactive refresh checks | +| `proactiveRefreshIntervalMs` | `60000` | Refresh guardian polling interval | +| `proactiveRefreshBufferMs` | `300000` | Refresh-before-expiry buffer | +| `storageBackupEnabled` | `true` | Write rotating account-storage backups | +| `sessionRecovery` | `true` | Restore recoverable conversation state | +| `autoResume` | `true` | Automatically resume recoverable sessions | ### Performance & Timeouts -Examples: - -- `parallelProbing` -- `parallelProbingMaxConcurrency` -- `fastSession` -- `fastSessionStrategy` -- `fastSessionMaxInputItems` -- `emptyResponseMaxRetries` -- `emptyResponseRetryDelayMs` -- `fetchTimeoutMs` -- `streamStallTimeoutMs` -- `networkErrorCooldownMs` -- `serverErrorCooldownMs` +| Key | Default | Effect | +| --- | --- | --- | +| `parallelProbing` | `false` | Probe multiple accounts concurrently | +| `parallelProbingMaxConcurrency` | `2` | Concurrency cap for parallel probing | +| `fastSession` | `false` | Enable fast-session request trimming | +| `fastSessionStrategy` | `hybrid` | Choose fast-session trimming strategy | +| `fastSessionMaxInputItems` | `30` | Cap history items in fast-session mode | +| `emptyResponseMaxRetries` | `2` | Retries for empty/invalid responses | +| `emptyResponseRetryDelayMs` | `1000` | Delay between empty-response retries | +| `fetchTimeoutMs` | `60000` | Request timeout | +| `streamStallTimeoutMs` | `45000` | Stream stall timeout | +| `networkErrorCooldownMs` | `6000` | Cooldown after network failures | +| `serverErrorCooldownMs` | `4000` | Cooldown after server failures | --- @@ -166,8 +192,6 @@ Common operator overrides: - `CODEX_AUTH_FETCH_TIMEOUT_MS` - `CODEX_AUTH_STREAM_STALL_TIMEOUT_MS` ---- - ## Advanced and Internal Overrides Maintainer/debug-focused overrides include: From 1c13710e53470957ff263c9fe52757de3a77f9df Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:27:22 +0800 Subject: [PATCH 288/376] Fix config explain and restore review gaps --- lib/config.ts | 68 +++- lib/runtime/account-check.ts | 20 +- lib/storage.ts | 11 +- lib/storage/migration-helpers.ts | 5 + lib/storage/restore-assessment.ts | 53 +++- test/codex-manager-cli.test.ts | 19 ++ test/config-explain.test.ts | 94 +++++- test/migration-helpers.test.ts | 152 ++++++++- test/restore-assessment.test.ts | 269 ++++++++++++++-- test/runtime-account-check.test.ts | 430 ++++++++++++++++++++++++-- test/runtime-auth-facade.test.ts | 83 ++++- test/runtime-session-recovery.test.ts | 6 +- test/runtime-verify-flagged.test.ts | 130 +++++++- 13 files changed, 1231 insertions(+), 109 deletions(-) diff --git a/lib/config.ts b/lib/config.ts index 709f4d16..7a04a523 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -44,7 +44,21 @@ const RETRYABLE_FS_CODES = new Set(["EBUSY", "EPERM"]); export type UnsupportedCodexPolicy = "strict" | "fallback"; -export type ConfigExplainSource = "env" | "unified" | "file" | "default"; +type ConfigExplainStorageKind = + | "unified" + | "file" + | "none" + | "unreadable"; + +type ConfigExplainStoredSource = Extract< + ConfigExplainStorageKind, + "unified" | "file" +>; + +export type ConfigExplainSource = + | "env" + | ConfigExplainStoredSource + | "default"; export interface ConfigExplainEntry { key: keyof PluginConfig; @@ -56,7 +70,7 @@ export interface ConfigExplainEntry { export interface ConfigExplainReport { configPath: string | null; - storageKind: "unified" | "file" | "none"; + storageKind: ConfigExplainStorageKind; entries: ConfigExplainEntry[]; } @@ -377,7 +391,7 @@ function readConfigRecordFromPath( function resolveStoredPluginConfigRecord(): { configPath: string | null; - storageKind: "unified" | "file" | "none"; + storageKind: ConfigExplainStorageKind; record: Record | null; } { const unifiedConfig = loadUnifiedPluginConfigSync(); @@ -398,10 +412,19 @@ function resolveStoredPluginConfigRecord(): { }; } + const record = readConfigRecordFromPath(configPath); + if (record) { + return { + configPath, + storageKind: "file", + record, + }; + } + return { configPath, - storageKind: "file", - record: readConfigRecordFromPath(configPath), + storageKind: existsSync(configPath) ? "unreadable" : "none", + record: null, }; } @@ -1244,31 +1267,42 @@ function resolveConfigExplainSource( entry: ConfigExplainMeta, pluginConfig: PluginConfig, storedRecord: Partial | null, - storageKind: "unified" | "file" | "none", + storageKind: ConfigExplainStorageKind, ): ConfigExplainSource { const effectiveValue = entry.getValue(pluginConfig); const noEnvValue = withExplainEnvUnset(entry.envNames, () => entry.getValue(pluginConfig)); if (!configExplainValuesEqual(effectiveValue, noEnvValue)) { return "env"; } - const defaultResolvedValue = withExplainEnvUnset(entry.envNames, () => - // empty config to trigger default-resolution path in getters - entry.getValue({} as PluginConfig), - ); const storedKeys = entry.sourceKeys ?? [entry.key]; const hasStoredSource = - storageKind !== "none" && + (storageKind === "unified" || storageKind === "file") && storedRecord !== null && storedKeys.some((key) => Object.hasOwn(storedRecord, key)); - if (hasStoredSource && !configExplainValuesEqual(noEnvValue, defaultResolvedValue)) { - return storageKind; - } - if (hasStoredSource && storedKeys.length > 1) { + if (hasStoredSource) { return storageKind; } return "default"; } +function normalizeConfigExplainValue(value: unknown): unknown { + if (typeof value === "number" && !Number.isFinite(value)) { + if (Number.isNaN(value)) return "NaN"; + return value > 0 ? "Infinity" : "-Infinity"; + } + if (Array.isArray(value)) { + return value.map((item) => normalizeConfigExplainValue(item)); + } + if (isRecord(value)) { + const normalized: Record = {}; + for (const [key, item] of Object.entries(value)) { + normalized[key] = normalizeConfigExplainValue(item); + } + return normalized; + } + return value; +} + const CONFIG_EXPLAIN_ENTRIES: ConfigExplainMeta[] = [ { key: "codexMode", envNames: ["CODEX_MODE"], getValue: getCodexMode }, { key: "codexTuiV2", envNames: ["CODEX_TUI_V2"], getValue: getCodexTuiV2 }, @@ -1495,8 +1529,8 @@ export function getPluginConfigExplainReport(): ConfigExplainReport { const value = entry.getValue(pluginConfig); return { key: entry.key, - value, - defaultValue: DEFAULT_PLUGIN_CONFIG[entry.key], + value: normalizeConfigExplainValue(value), + defaultValue: normalizeConfigExplainValue(DEFAULT_PLUGIN_CONFIG[entry.key]), source: resolveConfigExplainSource(entry, pluginConfig, storedRecord, stored.storageKind), envNames: entry.envNames, }; diff --git a/lib/runtime/account-check.ts b/lib/runtime/account-check.ts index a99c89f8..ec753acc 100644 --- a/lib/runtime/account-check.ts +++ b/lib/runtime/account-check.ts @@ -188,7 +188,18 @@ export async function runRuntimeAccountCheck( } if (!accessToken) { - const refreshResult = await deps.queuedRefresh(account.refreshToken); + const refreshToken = + typeof account.refreshToken === "string" + ? account.refreshToken.trim() + : ""; + if (refreshToken.length === 0) { + state.errors += 1; + deps.showLine( + `[${i + 1}/${total}] ${label}: ERROR (missing refreshToken)`, + ); + continue; + } + const refreshResult = await deps.queuedRefresh(refreshToken); if (refreshResult.type !== "success") { state.errors += 1; const message = @@ -196,10 +207,11 @@ export async function runRuntimeAccountCheck( deps.showLine(`[${i + 1}/${total}] ${label}: ERROR (${message})`); if (deepProbe && deps.isRuntimeFlaggableFailure(refreshResult)) { const existingIndex = state.flaggedStorage.accounts.findIndex( - (flagged) => flagged.refreshToken === account.refreshToken, + (flagged) => flagged.refreshToken === refreshToken, ); const flaggedRecord: FlaggedAccountMetadataV1 = { ...account, + refreshToken, flaggedAt: nowMs, flaggedReason: "token-invalid", lastError: message, @@ -209,7 +221,7 @@ export async function runRuntimeAccountCheck( } else { state.flaggedStorage.accounts.push(flaggedRecord); } - state.removeFromActive.add(account.refreshToken); + state.removeFromActive.add(refreshToken); state.flaggedChanged = true; } continue; @@ -258,6 +270,8 @@ export async function runRuntimeAccountCheck( } if (!accessToken) { + // Defensive guard: current acquisition paths either populate accessToken or + // return early, but keep this check to catch future refactors. throw new Error("Missing access token after refresh"); } diff --git a/lib/storage.ts b/lib/storage.ts index 4c812b36..1c1583b3 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -108,7 +108,11 @@ export interface FlaggedAccountStorageV1 { accounts: FlaggedAccountMetadataV1[]; } -type RestoreReason = "empty-storage" | "intentional-reset" | "missing-storage"; +type RestoreReason = + | "corrupted-primary" + | "empty-storage" + | "intentional-reset" + | "missing-storage"; type AccountStorageWithMetadata = AccountStorageV3 & { restoreEligible?: boolean; @@ -770,7 +774,10 @@ async function migrateLegacyProjectStorageIfNeeded( const mergedStorage = mergeStorageForMigration( targetStorage, legacyStorage, - { normalizeAccountStorage }, + { + normalizeAccountStorage, + logWarn: (message, meta) => log.warn(message, meta), + }, ); const fallbackStorage = targetStorage ?? legacyStorage; diff --git a/lib/storage/migration-helpers.ts b/lib/storage/migration-helpers.ts index 7c8fc93a..0907fff2 100644 --- a/lib/storage/migration-helpers.ts +++ b/lib/storage/migration-helpers.ts @@ -47,6 +47,7 @@ export function mergeStorageForMigration( incoming: AccountStorageV3, deps: { normalizeAccountStorage: (data: unknown) => AccountStorageV3 | null; + logWarn?: (message: string, meta: Record) => void; }, ): AccountStorageV3 { if (!current) { @@ -60,6 +61,10 @@ export function mergeStorageForMigration( accounts: [...current.accounts, ...incoming.accounts], }); if (!merged) { + deps.logWarn?.("Failed to merge legacy storage, incoming accounts dropped", { + currentCount: current.accounts.length, + incomingCount: incoming.accounts.length, + }); return current; } return merged; diff --git a/lib/storage/restore-assessment.ts b/lib/storage/restore-assessment.ts index eb6df5b2..12108f64 100644 --- a/lib/storage/restore-assessment.ts +++ b/lib/storage/restore-assessment.ts @@ -7,6 +7,24 @@ function normalizeSnapshotPath(path: string): string { return path.replaceAll("\\", "/"); } +function resolveBackupKind( + candidatePath: string, + storagePath: string, + backupKind: BackupSnapshotKind, + historyKind: BackupSnapshotKind, + discoveredKind: BackupSnapshotKind, +): BackupSnapshotKind { + const normalizedCandidatePath = normalizeSnapshotPath(candidatePath); + const normalizedStoragePath = normalizeSnapshotPath(storagePath); + if (normalizedCandidatePath === `${normalizedStoragePath}.bak`) { + return backupKind; + } + if (normalizedCandidatePath.startsWith(`${normalizedStoragePath}.bak.`)) { + return historyKind; + } + return discoveredKind; +} + function resolveLatestSnapshot(backupMetadata: BackupMetadata): BackupSnapshotMetadata | undefined { const latestValidPath = backupMetadata.accounts.latestValidPath; if (!latestValidPath) return undefined; @@ -51,12 +69,13 @@ export async function collectBackupMetadata(deps: { await deps.describeAccountsWalSnapshot(walPath), ]; for (const [index, candidate] of accountCandidates.entries()) { - const kind: BackupSnapshotKind = - candidate === `${deps.storagePath}.bak` - ? "accounts-backup" - : candidate.startsWith(`${deps.storagePath}.bak.`) - ? "accounts-backup-history" - : "accounts-discovered-backup"; + const kind = resolveBackupKind( + candidate, + deps.storagePath, + "accounts-backup", + "accounts-backup-history", + "accounts-discovered-backup", + ); accountSnapshots.push( await deps.describeAccountSnapshot(candidate, kind, index), ); @@ -70,12 +89,13 @@ export async function collectBackupMetadata(deps: { await deps.describeFlaggedSnapshot(deps.flaggedPath, "flagged-primary"), ]; for (const [index, candidate] of flaggedCandidates.entries()) { - const kind: BackupSnapshotKind = - candidate === `${deps.flaggedPath}.bak` - ? "flagged-backup" - : candidate.startsWith(`${deps.flaggedPath}.bak.`) - ? "flagged-backup-history" - : "flagged-discovered-backup"; + const kind = resolveBackupKind( + candidate, + deps.flaggedPath, + "flagged-backup", + "flagged-backup-history", + "flagged-discovered-backup", + ); flaggedSnapshots.push( await deps.describeFlaggedSnapshot(candidate, kind, index), ); @@ -125,6 +145,15 @@ export function buildRestoreAssessment(deps: { backupMetadata: deps.backupMetadata, }; } + if (primarySnapshot.exists && !primarySnapshot.valid) { + return { + storagePath: deps.storagePath, + restoreEligible: true, + restoreReason: "corrupted-primary", + latestSnapshot: resolveLatestSnapshot(deps.backupMetadata) ?? primarySnapshot, + backupMetadata: deps.backupMetadata, + }; + } return { storagePath: deps.storagePath, restoreEligible: false, diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index e4aa7be2..fa9b17bc 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -674,6 +674,19 @@ describe("codex manager cli commands", () => { preemptiveQuotaRemainingPercent7d: 5, preemptiveQuotaMaxDeferralMs: 7200000, }); + getPluginConfigExplainReportMock.mockReturnValueOnce({ + configPath: "/mock/settings.json", + storageKind: "unified", + entries: [ + { + key: "retryAllAccountsMaxRetries", + value: "Infinity", + defaultValue: "Infinity", + source: "default", + envNames: [], + }, + ], + }); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); @@ -689,6 +702,12 @@ describe("codex manager cli commands", () => { expect.stringContaining('"storageKind"'), ); expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"entries"')); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('"key": "retryAllAccountsMaxRetries"'), + ); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('"value": "Infinity"'), + ); }); it("prints config explain output in text mode", async () => { diff --git a/test/config-explain.test.ts b/test/config-explain.test.ts index ddd02f65..3da78dde 100644 --- a/test/config-explain.test.ts +++ b/test/config-explain.test.ts @@ -11,8 +11,24 @@ vi.mock("../lib/unified-settings.js", () => ({ saveUnifiedPluginConfig: vi.fn(), })); +let tempConfigCounter = 0; + +function nextConfigPath(label: string): string { + tempConfigCounter += 1; + return join(tmpdir(), `config-explain-${label}-${tempConfigCounter}.json`); +} + +function expectEntry( + report: { entries: Array<{ key: string }> }, + key: string, +) { + const entry = report.entries.find((item) => item.key === key); + expect(entry).toBeDefined(); + return entry; +} + describe("getPluginConfigExplainReport", () => { - afterEach(async () => { + afterEach(() => { delete process.env.CODEX_MODE; delete process.env.CODEX_AUTH_FAST_SESSION_STRATEGY; delete process.env.CODEX_MULTI_AUTH_CONFIG_PATH; @@ -41,13 +57,15 @@ describe("getPluginConfigExplainReport", () => { process.env.CODEX_AUTH_FAST_SESSION_STRATEGY = "bogus"; const { getPluginConfigExplainReport } = await import("../lib/config.js"); const report = getPluginConfigExplainReport(); - const entry = report.entries.find((item) => item.key === "fastSessionStrategy"); + const entry = report.entries.find( + (item) => item.key === "fastSessionStrategy", + ); expect(entry).toBeDefined(); expect(entry?.source).not.toBe("env"); }); it("attributes alias-backed fallback policy values to stored config", async () => { - const configPath = join(tmpdir(), `config-explain-${Date.now()}.json`); + const configPath = nextConfigPath("alias"); process.env.CODEX_MULTI_AUTH_CONFIG_PATH = configPath; try { await fs.writeFile( @@ -57,10 +75,8 @@ describe("getPluginConfigExplainReport", () => { ); const { getPluginConfigExplainReport } = await import("../lib/config.js"); const report = getPluginConfigExplainReport(); - const policy = report.entries.find((item) => item.key === "unsupportedCodexPolicy"); - const fallback = report.entries.find((item) => item.key === "fallbackOnUnsupportedCodexModel"); - expect(policy).toBeDefined(); - expect(fallback).toBeDefined(); + const policy = expectEntry(report, "unsupportedCodexPolicy"); + const fallback = expectEntry(report, "fallbackOnUnsupportedCodexModel"); expect(policy?.source).toBe("file"); expect(fallback?.source).toBe("file"); } finally { @@ -68,12 +84,76 @@ describe("getPluginConfigExplainReport", () => { } }); + it("reports missing config files as none", async () => { + process.env.CODEX_MULTI_AUTH_CONFIG_PATH = nextConfigPath("missing"); + const { getPluginConfigExplainReport } = await import("../lib/config.js"); + const report = getPluginConfigExplainReport(); + const entry = expectEntry(report, "codexMode"); + expect(report.storageKind).toBe("none"); + expect(entry?.source).toBe("default"); + }); + + it("attributes stored single-key defaults to file config", async () => { + const configPath = nextConfigPath("single-key-default"); + process.env.CODEX_MULTI_AUTH_CONFIG_PATH = configPath; + try { + await fs.writeFile( + configPath, + JSON.stringify({ codexMode: true }, null, 2), + "utf-8", + ); + const { getPluginConfigExplainReport } = await import("../lib/config.js"); + const report = getPluginConfigExplainReport(); + const entry = expectEntry(report, "codexMode"); + expect(report.storageKind).toBe("file"); + expect(entry?.source).toBe("file"); + } finally { + await fs.unlink(configPath).catch(() => {}); + } + }); + + it("reports unreadable config files consistently", async () => { + const configPath = nextConfigPath("malformed"); + process.env.CODEX_MULTI_AUTH_CONFIG_PATH = configPath; + try { + await fs.writeFile(configPath, "{ malformed-json", "utf-8"); + const { getPluginConfigExplainReport } = await import("../lib/config.js"); + const report = getPluginConfigExplainReport(); + const policy = expectEntry(report, "unsupportedCodexPolicy"); + const fallback = expectEntry(report, "fallbackOnUnsupportedCodexModel"); + expect(report.storageKind).toBe("unreadable"); + expect(policy?.source).not.toBe("file"); + expect(fallback?.source).not.toBe("file"); + } finally { + await fs.unlink(configPath).catch(() => {}); + } + }); + + it("normalizes non-finite values for json-safe output", async () => { + const { getPluginConfigExplainReport } = await import("../lib/config.js"); + const report = getPluginConfigExplainReport(); + const entry = expectEntry(report, "retryAllAccountsMaxRetries"); + expect(entry?.value).toBe("Infinity"); + expect(entry?.defaultValue).toBe("Infinity"); + const serialized = JSON.parse(JSON.stringify(report)) as { + entries: Array<{ key: string; value: unknown; defaultValue: unknown }>; + }; + const serializedEntry = serialized.entries.find( + (item) => item.key === "retryAllAccountsMaxRetries", + ); + expect(serializedEntry).toMatchObject({ + value: "Infinity", + defaultValue: "Infinity", + }); + }); + it("reports default and env sources", async () => { const mod = await import("../lib/config.js"); let report = mod.getPluginConfigExplainReport(); let entry = report.entries.find((item) => item.key === "codexMode"); expect(entry).toBeDefined(); expect(entry?.source).toBe("default"); + expect(report.storageKind).toBe("none"); vi.resetModules(); process.env.CODEX_AUTH_FAST_SESSION_STRATEGY = "always"; diff --git a/test/migration-helpers.test.ts b/test/migration-helpers.test.ts index a1806428..f84587e8 100644 --- a/test/migration-helpers.test.ts +++ b/test/migration-helpers.test.ts @@ -1,28 +1,170 @@ import { describe, expect, it, vi } from "vitest"; -import { loadNormalizedStorageFromPathOrNull } from "../lib/storage/migration-helpers.js"; +import { + loadNormalizedStorageFromPathOrNull, + mergeStorageForMigration, +} from "../lib/storage/migration-helpers.js"; + +const currentStorage = { + version: 3 as const, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [{ refreshToken: "current-refresh", addedAt: 1, lastUsed: 1 }], +}; + +const incomingStorage = { + version: 3 as const, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [{ refreshToken: "incoming-refresh", addedAt: 2, lastUsed: 2 }], +}; describe("loadNormalizedStorageFromPathOrNull", () => { it("retries transient lock errors before succeeding", async () => { const sleep = vi.fn(async () => {}); + const logWarn = vi.fn(); const loadAccountsFromPath = vi .fn() .mockRejectedValueOnce(Object.assign(new Error("busy"), { code: "EBUSY" })) .mockRejectedValueOnce(Object.assign(new Error("again"), { code: "EAGAIN" })) - .mockResolvedValueOnce({ normalized: { version: 3, activeIndex: 0, activeIndexByFamily: {}, accounts: [] }, schemaErrors: [] }); - const result = await loadNormalizedStorageFromPathOrNull("legacy.json", "legacy storage", { loadAccountsFromPath, logWarn: vi.fn(), sleep }); + .mockResolvedValueOnce({ + normalized: { + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [], + }, + schemaErrors: [], + }); + const result = await loadNormalizedStorageFromPathOrNull( + "legacy.json", + "legacy storage", + { + loadAccountsFromPath, + logWarn, + sleep, + }, + ); expect(result).toMatchObject({ version: 3, accounts: [] }); expect(loadAccountsFromPath).toHaveBeenCalledTimes(3); + expect(logWarn).not.toHaveBeenCalled(); expect(sleep).toHaveBeenNthCalledWith(1, 10); expect(sleep).toHaveBeenNthCalledWith(2, 20); }); + it("logs schema validation warnings and still returns normalized storage", async () => { + const logWarn = vi.fn(); + const normalized = { + version: 3 as const, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [], + }; + const result = await loadNormalizedStorageFromPathOrNull( + "legacy.json", + "legacy storage", + { + loadAccountsFromPath: vi.fn(async () => ({ + normalized, + schemaErrors: ["missing refreshToken"], + })), + logWarn, + }, + ); + expect(result).toBe(normalized); + expect(logWarn).toHaveBeenCalledWith( + "legacy storage schema validation warnings", + { + path: "legacy.json", + errors: ["missing refreshToken"], + }, + ); + }); + + it("returns null without warning when the file is missing", async () => { + const logWarn = vi.fn(); + const result = await loadNormalizedStorageFromPathOrNull( + "legacy.json", + "legacy storage", + { + loadAccountsFromPath: vi + .fn() + .mockRejectedValue(Object.assign(new Error("missing"), { code: "ENOENT" })), + logWarn, + }, + ); + expect(result).toBeNull(); + expect(logWarn).not.toHaveBeenCalled(); + }); + it("returns null and logs once after retry budget is exhausted", async () => { const logWarn = vi.fn(); const sleep = vi.fn(async () => {}); - const loadAccountsFromPath = vi.fn().mockRejectedValue(Object.assign(new Error("locked"), { code: "EPERM" })); - const result = await loadNormalizedStorageFromPathOrNull("legacy.json", "legacy storage", { loadAccountsFromPath, logWarn, sleep }); + const loadAccountsFromPath = vi + .fn() + .mockRejectedValue(Object.assign(new Error("locked"), { code: "EPERM" })); + const result = await loadNormalizedStorageFromPathOrNull( + "legacy.json", + "legacy storage", + { + loadAccountsFromPath, + logWarn, + sleep, + }, + ); expect(result).toBeNull(); expect(loadAccountsFromPath).toHaveBeenCalledTimes(4); expect(logWarn).toHaveBeenCalledTimes(1); + expect(logWarn).toHaveBeenCalledWith( + expect.stringContaining("Failed to load"), + expect.objectContaining({ path: "legacy.json" }), + ); + }); +}); + +describe("mergeStorageForMigration", () => { + it("returns incoming storage when there is no current storage", () => { + const normalizeAccountStorage = vi.fn(); + expect( + mergeStorageForMigration(null, incomingStorage, { + normalizeAccountStorage, + }), + ).toBe(incomingStorage); + expect(normalizeAccountStorage).not.toHaveBeenCalled(); + }); + + it("returns the normalized merged storage when normalization succeeds", () => { + const mergedStorage = { + version: 3 as const, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [currentStorage.accounts[0], incomingStorage.accounts[0]], + }; + const normalizeAccountStorage = vi.fn(() => mergedStorage); + const result = mergeStorageForMigration(currentStorage, incomingStorage, { + normalizeAccountStorage, + }); + expect(result).toBe(mergedStorage); + expect(normalizeAccountStorage).toHaveBeenCalledWith({ + version: 3, + activeIndex: currentStorage.activeIndex, + activeIndexByFamily: currentStorage.activeIndexByFamily, + accounts: [...currentStorage.accounts, ...incomingStorage.accounts], + }); + }); + + it("logs and falls back to current storage when normalization fails", () => { + const logWarn = vi.fn(); + const result = mergeStorageForMigration(currentStorage, incomingStorage, { + normalizeAccountStorage: vi.fn(() => null), + logWarn, + }); + expect(result).toBe(currentStorage); + expect(logWarn).toHaveBeenCalledWith( + "Failed to merge legacy storage, incoming accounts dropped", + { + currentCount: currentStorage.accounts.length, + incomingCount: incomingStorage.accounts.length, + }, + ); }); }); diff --git a/test/restore-assessment.test.ts b/test/restore-assessment.test.ts index e36e4884..eafe37f1 100644 --- a/test/restore-assessment.test.ts +++ b/test/restore-assessment.test.ts @@ -1,5 +1,151 @@ -import { describe, expect, it } from "vitest"; -import { buildRestoreAssessment } from "../lib/storage/restore-assessment.js"; +import { describe, expect, it, vi } from "vitest"; +import type { BackupSnapshotMetadata } from "../lib/storage/backup-metadata.js"; +import { + buildRestoreAssessment, + collectBackupMetadata, +} from "../lib/storage/restore-assessment.js"; + +function buildSection( + storagePath: string, + snapshots: BackupSnapshotMetadata[], + latestValidPath?: string, +) { + return { + storagePath, + latestValidPath, + snapshotCount: snapshots.length, + validSnapshotCount: snapshots.filter((snapshot) => snapshot.valid).length, + snapshots, + }; +} + +describe("collectBackupMetadata", () => { + it("assigns backup kinds across slash styles", async () => { + const describeAccountSnapshot = vi.fn( + async (path: string, kind: BackupSnapshotMetadata["kind"], index?: number) => ({ + path, + kind, + index, + exists: true, + valid: true, + }), + ); + const describeAccountsWalSnapshot = vi.fn(async (path: string) => ({ + path, + kind: "accounts-wal" as const, + exists: true, + valid: true, + })); + const describeFlaggedSnapshot = vi.fn( + async (path: string, kind: BackupSnapshotMetadata["kind"], index?: number) => ({ + path, + kind, + index, + exists: true, + valid: true, + }), + ); + const buildMetadataSection = vi.fn(buildSection); + + const metadata = await collectBackupMetadata({ + storagePath: "C:/repo/accounts.json", + flaggedPath: "C:/repo/flagged.json", + getAccountsWalPath: (path) => `${path}.wal`, + getAccountsBackupRecoveryCandidatesWithDiscovery: async (path) => + path === "C:/repo/accounts.json" + ? [ + "C:\\repo\\accounts.json.bak", + "C:\\repo\\accounts.json.bak.1", + "C:/repo/accounts.json.discovered", + ] + : [ + "C:\\repo\\flagged.json.bak", + "C:\\repo\\flagged.json.bak.1", + "C:/repo/flagged.json.discovered", + ], + describeAccountSnapshot, + describeAccountsWalSnapshot, + describeFlaggedSnapshot, + buildMetadataSection, + }); + + expect(describeAccountsWalSnapshot).toHaveBeenCalledWith("C:/repo/accounts.json.wal"); + expect(describeAccountSnapshot).toHaveBeenNthCalledWith( + 2, + "C:\\repo\\accounts.json.bak", + "accounts-backup", + 0, + ); + expect(describeAccountSnapshot).toHaveBeenNthCalledWith( + 3, + "C:\\repo\\accounts.json.bak.1", + "accounts-backup-history", + 1, + ); + expect(describeAccountSnapshot).toHaveBeenNthCalledWith( + 4, + "C:/repo/accounts.json.discovered", + "accounts-discovered-backup", + 2, + ); + expect(describeFlaggedSnapshot).toHaveBeenNthCalledWith( + 2, + "C:\\repo\\flagged.json.bak", + "flagged-backup", + 0, + ); + expect(describeFlaggedSnapshot).toHaveBeenNthCalledWith( + 3, + "C:\\repo\\flagged.json.bak.1", + "flagged-backup-history", + 1, + ); + expect(describeFlaggedSnapshot).toHaveBeenNthCalledWith( + 4, + "C:/repo/flagged.json.discovered", + "flagged-discovered-backup", + 2, + ); + expect(buildMetadataSection).toHaveBeenNthCalledWith( + 1, + "C:/repo/accounts.json", + expect.arrayContaining([expect.objectContaining({ kind: "accounts-wal" })]), + ); + expect(metadata.accounts.snapshotCount).toBe(5); + expect(metadata.flaggedAccounts.snapshotCount).toBe(4); + }); + + it("handles empty candidate lists without error", async () => { + const metadata = await collectBackupMetadata({ + storagePath: "C:/repo/accounts.json", + flaggedPath: "C:/repo/flagged.json", + getAccountsWalPath: (path) => `${path}.wal`, + getAccountsBackupRecoveryCandidatesWithDiscovery: async () => [], + describeAccountSnapshot: async (path, kind) => ({ + path, + kind, + exists: true, + valid: true, + }), + describeAccountsWalSnapshot: async (path) => ({ + path, + kind: "accounts-wal", + exists: true, + valid: true, + }), + describeFlaggedSnapshot: async (path, kind) => ({ + path, + kind, + exists: true, + valid: true, + }), + buildMetadataSection: buildSection, + }); + + expect(metadata.accounts.snapshotCount).toBe(2); + expect(metadata.flaggedAccounts.snapshotCount).toBe(1); + }); +}); describe("buildRestoreAssessment", () => { it("prefers the latest valid backup over an empty primary", () => { @@ -7,15 +153,27 @@ describe("buildRestoreAssessment", () => { storagePath: "C:/repo/accounts.json", resetMarkerExists: false, backupMetadata: { - accounts: { - path: "C:/repo/accounts.json", - latestValidPath: "C:/repo/accounts.json.bak", - snapshots: [ - { kind: "accounts-primary", path: "C:/repo/accounts.json", exists: true, valid: true, accountCount: 0 }, - { kind: "accounts-backup", path: "C:/repo/accounts.json.bak", exists: true, valid: true, accountCount: 2 }, + accounts: buildSection( + "C:/repo/accounts.json", + [ + { + kind: "accounts-primary", + path: "C:/repo/accounts.json", + exists: true, + valid: true, + accountCount: 0, + }, + { + kind: "accounts-backup", + path: "C:/repo/accounts.json.bak", + exists: true, + valid: true, + accountCount: 2, + }, ], - }, - flaggedAccounts: { path: "C:/repo/flagged.json", latestValidPath: undefined, snapshots: [] }, + "C:/repo/accounts.json.bak", + ), + flaggedAccounts: buildSection("C:/repo/flagged.json", []), }, }); expect(assessment.restoreEligible).toBe(true); @@ -28,19 +186,94 @@ describe("buildRestoreAssessment", () => { storagePath: "C:/repo/accounts.json", resetMarkerExists: false, backupMetadata: { - accounts: { - path: "C:/repo/accounts.json", - latestValidPath: "C:\\repo\\accounts.json.bak", - snapshots: [ - { kind: "accounts-primary", path: "C:/repo/accounts.json", exists: false, valid: false }, - { kind: "accounts-backup", path: "C:/repo/accounts.json.bak", exists: true, valid: true, accountCount: 1 }, + accounts: buildSection( + "C:/repo/accounts.json", + [ + { + kind: "accounts-primary", + path: "C:/repo/accounts.json", + exists: false, + valid: false, + }, + { + kind: "accounts-backup", + path: "C:/repo/accounts.json.bak", + exists: true, + valid: true, + accountCount: 1, + }, ], - }, - flaggedAccounts: { path: "C:/repo/flagged.json", latestValidPath: undefined, snapshots: [] }, + "C:\\repo\\accounts.json.bak", + ), + flaggedAccounts: buildSection("C:/repo/flagged.json", []), }, }); expect(assessment.restoreEligible).toBe(true); expect(assessment.restoreReason).toBe("missing-storage"); expect(assessment.latestSnapshot?.path).toBe("C:/repo/accounts.json.bak"); }); + + it("returns intentional-reset when the reset marker exists", () => { + const assessment = buildRestoreAssessment({ + storagePath: "C:/repo/accounts.json", + resetMarkerExists: true, + backupMetadata: { + accounts: buildSection( + "C:/repo/accounts.json", + [ + { + kind: "accounts-primary", + path: "C:/repo/accounts.json", + exists: true, + valid: true, + accountCount: 3, + }, + { + kind: "accounts-backup", + path: "C:/repo/accounts.json.bak", + exists: true, + valid: true, + accountCount: 3, + }, + ], + "C:/repo/accounts.json.bak", + ), + flaggedAccounts: buildSection("C:/repo/flagged.json", []), + }, + }); + expect(assessment.restoreEligible).toBe(false); + expect(assessment.restoreReason).toBe("intentional-reset"); + }); + + it("restores from the latest backup when the primary exists but is invalid", () => { + const assessment = buildRestoreAssessment({ + storagePath: "C:/repo/accounts.json", + resetMarkerExists: false, + backupMetadata: { + accounts: buildSection( + "C:/repo/accounts.json", + [ + { + kind: "accounts-primary", + path: "C:/repo/accounts.json", + exists: true, + valid: false, + }, + { + kind: "accounts-backup", + path: "C:/repo/accounts.json.bak", + exists: true, + valid: true, + accountCount: 3, + }, + ], + "C:/repo/accounts.json.bak", + ), + flaggedAccounts: buildSection("C:/repo/flagged.json", []), + }, + }); + expect(assessment.restoreEligible).toBe(true); + expect(assessment.restoreReason).toBe("corrupted-primary"); + expect(assessment.latestSnapshot?.path).toBe("C:/repo/accounts.json.bak"); + }); }); diff --git a/test/runtime-account-check.test.ts b/test/runtime-account-check.test.ts index accd387e..0610f823 100644 --- a/test/runtime-account-check.test.ts +++ b/test/runtime-account-check.test.ts @@ -1,15 +1,41 @@ import { describe, expect, it, vi } from "vitest"; +import type { FlaggedAccountMetadataV1 } from "../lib/storage.js"; import { runRuntimeAccountCheck } from "../lib/runtime/account-check.js"; +function createEmptyStorage() { + return { + version: 3 as const, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }; +} + +function createWorkingState(flaggedStorage: { + version: 1; + accounts: FlaggedAccountMetadataV1[]; +}) { + return { + flaggedStorage, + removeFromActive: new Set(), + storageChanged: false, + flaggedChanged: false, + ok: 0, + errors: 0, + disabled: 0, + }; +} + describe("runRuntimeAccountCheck", () => { it("reports when there are no accounts to check", async () => { const showLine = vi.fn(); await runRuntimeAccountCheck(false, { hydrateEmails: async (storage) => storage, loadAccounts: async () => null, - createEmptyStorage: () => ({ version: 3, accounts: [], activeIndex: 0, activeIndexByFamily: {} }), + createEmptyStorage, loadFlaggedAccounts: async () => ({ version: 1, accounts: [] }), - createAccountCheckWorkingState: () => ({ flaggedStorage: { version: 1, accounts: [] }, removeFromActive: new Set(), storageChanged: false, flaggedChanged: false, ok: 0, errors: 0, disabled: 0 }), + createAccountCheckWorkingState: () => + createWorkingState({ version: 1, accounts: [] }), lookupCodexCliTokensByEmail: async () => null, extractAccountId: () => undefined, shouldUpdateAccountIdFromToken: () => false, @@ -31,6 +57,8 @@ describe("runRuntimeAccountCheck", () => { }); it("reuses the current time when flagging an invalid refresh token", async () => { + const saveAccounts = vi.fn(async () => {}); + const invalidateAccountManagerCache = vi.fn(); const saveFlaggedAccounts = vi.fn(async () => {}); const now = vi.fn(() => 1000 + now.mock.calls.length - 1); @@ -39,28 +67,41 @@ describe("runRuntimeAccountCheck", () => { loadAccounts: async () => ({ version: 3, accounts: [ - { email: "one@example.com", refreshToken: "refresh-1", accessToken: undefined, addedAt: 1, lastUsed: 1 }, + { + email: "one@example.com", + refreshToken: "refresh-1", + accessToken: undefined, + addedAt: 1, + lastUsed: 1, + }, ], activeIndex: 0, activeIndexByFamily: { codex: 0 }, }), - createEmptyStorage: () => ({ version: 3, accounts: [], activeIndex: 0, activeIndexByFamily: {} }), + createEmptyStorage, loadFlaggedAccounts: async () => ({ version: 1, accounts: [] }), - createAccountCheckWorkingState: (flaggedStorage) => ({ flaggedStorage, removeFromActive: new Set(), storageChanged: false, flaggedChanged: false, ok: 0, errors: 0, disabled: 0 }), + createAccountCheckWorkingState: (flaggedStorage) => + createWorkingState(flaggedStorage), lookupCodexCliTokensByEmail: async () => null, extractAccountId: () => undefined, shouldUpdateAccountIdFromToken: () => false, sanitizeEmail: (email) => email, extractAccountEmail: () => undefined, - queuedRefresh: async () => ({ type: "failed", reason: "invalid_grant", message: "refresh failed" }), + queuedRefresh: async () => ({ + type: "failed", + reason: "invalid_grant", + message: "refresh failed", + }), isRuntimeFlaggableFailure: () => true, - fetchCodexQuotaSnapshot: async () => { throw new Error("should not probe quota in deep mode"); }, + fetchCodexQuotaSnapshot: async () => { + throw new Error("should not probe quota in deep mode"); + }, resolveRequestAccountId: () => undefined, formatCodexQuotaLine: () => "quota", clampRuntimeActiveIndices: vi.fn(), MODEL_FAMILIES: ["codex"], - saveAccounts: vi.fn(async () => {}), - invalidateAccountManagerCache: vi.fn(), + saveAccounts, + invalidateAccountManagerCache, saveFlaggedAccounts, now, showLine: vi.fn(), @@ -69,21 +110,312 @@ describe("runRuntimeAccountCheck", () => { const flaggedStorage = saveFlaggedAccounts.mock.calls[0]?.[0]; expect(flaggedStorage.accounts).toHaveLength(1); expect(flaggedStorage.accounts[0]?.flaggedAt).toBe(1000); + expect(saveAccounts).toHaveBeenCalledWith( + expect.objectContaining({ accounts: [] }), + ); + expect(invalidateAccountManagerCache).toHaveBeenCalledTimes(1); expect(now).toHaveBeenCalledTimes(1); }); + + it("uses cached access tokens without refreshing", async () => { + const fixedNow = 1_000; + const lookupCodexCliTokensByEmail = vi.fn(async () => null); + const queuedRefresh = vi.fn(async () => ({ + type: "failed" as const, + reason: "invalid_grant", + })); + const fetchCodexQuotaSnapshot = vi.fn(async () => ({ + remaining5h: 1, + remaining7d: 2, + }) as never); + const showLine = vi.fn(); + + await runRuntimeAccountCheck(false, { + hydrateEmails: async (storage) => storage, + loadAccounts: async () => ({ + version: 3, + accounts: [ + { + email: "cached@example.com", + refreshToken: "r1", + accessToken: "cached-access", + addedAt: 1, + lastUsed: 1, + expiresAt: fixedNow + 60_000, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }), + createEmptyStorage, + loadFlaggedAccounts: async () => ({ version: 1, accounts: [] }), + createAccountCheckWorkingState: (flaggedStorage) => + createWorkingState(flaggedStorage), + lookupCodexCliTokensByEmail, + extractAccountId: () => undefined, + shouldUpdateAccountIdFromToken: () => false, + sanitizeEmail: (email) => email, + extractAccountEmail: () => undefined, + queuedRefresh, + isRuntimeFlaggableFailure: () => false, + fetchCodexQuotaSnapshot, + resolveRequestAccountId: () => "acct-cached", + formatCodexQuotaLine: () => "quota ok", + clampRuntimeActiveIndices: vi.fn(), + MODEL_FAMILIES: ["codex"], + saveAccounts: vi.fn(async () => {}), + invalidateAccountManagerCache: vi.fn(), + saveFlaggedAccounts: vi.fn(async () => {}), + now: () => fixedNow, + showLine, + }); + + expect(lookupCodexCliTokensByEmail).not.toHaveBeenCalled(); + expect(queuedRefresh).not.toHaveBeenCalled(); + expect(fetchCodexQuotaSnapshot).toHaveBeenCalledWith({ + accountId: "acct-cached", + accessToken: "cached-access", + }); + expect(showLine).toHaveBeenCalledWith( + expect.stringContaining("ca***@***.com: quota ok"), + ); + }); + + it("falls back to the Codex CLI cache before refreshing", async () => { + const fixedNow = 1_000; + const lookupCodexCliTokensByEmail = vi.fn(async () => ({ + accessToken: "cached-access", + refreshToken: "cached-refresh", + expiresAt: fixedNow + 60_000, + })); + const queuedRefresh = vi.fn(async () => ({ + type: "failed" as const, + reason: "invalid_grant", + })); + const saveAccounts = vi.fn(async () => {}); + const invalidateAccountManagerCache = vi.fn(); + + await runRuntimeAccountCheck(false, { + hydrateEmails: async (storage) => storage, + loadAccounts: async () => ({ + version: 3, + accounts: [ + { + email: "cache@example.com", + refreshToken: "old-refresh", + accessToken: undefined, + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }), + createEmptyStorage, + loadFlaggedAccounts: async () => ({ version: 1, accounts: [] }), + createAccountCheckWorkingState: (flaggedStorage) => + createWorkingState(flaggedStorage), + lookupCodexCliTokensByEmail, + extractAccountId: () => "acct-cache", + shouldUpdateAccountIdFromToken: () => true, + sanitizeEmail: (email) => email, + extractAccountEmail: () => "cache@example.com", + queuedRefresh, + isRuntimeFlaggableFailure: () => false, + fetchCodexQuotaSnapshot: vi.fn(async () => ({ + remaining5h: 1, + remaining7d: 2, + }) as never), + resolveRequestAccountId: () => undefined, + formatCodexQuotaLine: () => "quota ok", + clampRuntimeActiveIndices: vi.fn(), + MODEL_FAMILIES: ["codex"], + saveAccounts, + invalidateAccountManagerCache, + saveFlaggedAccounts: vi.fn(async () => {}), + now: () => fixedNow, + showLine: vi.fn(), + }); + + expect(lookupCodexCliTokensByEmail).toHaveBeenCalledWith("cache@example.com"); + expect(queuedRefresh).not.toHaveBeenCalled(); + expect(saveAccounts).toHaveBeenCalledWith( + expect.objectContaining({ + accounts: [ + expect.objectContaining({ + accessToken: "cached-access", + refreshToken: "cached-refresh", + expiresAt: fixedNow + 60_000, + accountId: "acct-cache", + }), + ], + }), + ); + expect(invalidateAccountManagerCache).toHaveBeenCalledTimes(1); + }); + + it("refreshes accounts successfully and persists updated credentials", async () => { + const fixedNow = 1_000; + const queuedRefresh = vi.fn(async () => ({ + type: "success" as const, + access: "refreshed-access", + refresh: "refreshed-refresh", + expires: fixedNow + 120_000, + idToken: "id-token", + })); + const saveAccounts = vi.fn(async () => {}); + const showLine = vi.fn(); + + await runRuntimeAccountCheck(false, { + hydrateEmails: async (storage) => storage, + loadAccounts: async () => ({ + version: 3, + accounts: [ + { + email: "refresh@example.com", + refreshToken: "old-refresh", + accessToken: undefined, + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }), + createEmptyStorage, + loadFlaggedAccounts: async () => ({ version: 1, accounts: [] }), + createAccountCheckWorkingState: (flaggedStorage) => + createWorkingState(flaggedStorage), + lookupCodexCliTokensByEmail: async () => null, + extractAccountId: () => "acct-refresh", + shouldUpdateAccountIdFromToken: () => true, + sanitizeEmail: (email) => email, + extractAccountEmail: () => "refresh@example.com", + queuedRefresh, + isRuntimeFlaggableFailure: () => false, + fetchCodexQuotaSnapshot: vi.fn(async () => ({ + remaining5h: 1, + remaining7d: 2, + }) as never), + resolveRequestAccountId: () => undefined, + formatCodexQuotaLine: () => "quota ok", + clampRuntimeActiveIndices: vi.fn(), + MODEL_FAMILIES: ["codex"], + saveAccounts, + invalidateAccountManagerCache: vi.fn(), + saveFlaggedAccounts: vi.fn(async () => {}), + now: () => fixedNow, + showLine, + }); + + expect(queuedRefresh).toHaveBeenCalledWith("old-refresh"); + expect(saveAccounts).toHaveBeenCalledWith( + expect.objectContaining({ + accounts: [ + expect.objectContaining({ + accessToken: "refreshed-access", + refreshToken: "refreshed-refresh", + expiresAt: fixedNow + 120_000, + email: "refresh@example.com", + accountId: "acct-refresh", + }), + ], + }), + ); + expect(showLine).toHaveBeenCalledWith( + expect.stringContaining("re***@***.com: quota ok"), + ); + }); + + it("reports missing refresh tokens without queuing refresh", async () => { + const queuedRefresh = vi.fn(async () => ({ + type: "success" as const, + access: "unused", + refresh: "unused", + expires: 2_000, + })); + const showLine = vi.fn(); + + await runRuntimeAccountCheck(true, { + hydrateEmails: async (storage) => storage, + loadAccounts: async () => ({ + version: 3, + accounts: [ + { + accountLabel: "Missing token", + refreshToken: undefined, + accessToken: undefined, + addedAt: 1, + lastUsed: 1, + }, + { + accountLabel: "Blank token", + refreshToken: " ", + accessToken: undefined, + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }), + createEmptyStorage, + loadFlaggedAccounts: async () => ({ version: 1, accounts: [] }), + createAccountCheckWorkingState: (flaggedStorage) => + createWorkingState(flaggedStorage), + lookupCodexCliTokensByEmail: async () => null, + extractAccountId: () => undefined, + shouldUpdateAccountIdFromToken: () => false, + sanitizeEmail: (email) => email, + extractAccountEmail: () => undefined, + queuedRefresh, + isRuntimeFlaggableFailure: () => true, + fetchCodexQuotaSnapshot: async () => { + throw new Error("should not probe quota"); + }, + resolveRequestAccountId: () => undefined, + formatCodexQuotaLine: () => "quota", + clampRuntimeActiveIndices: vi.fn(), + MODEL_FAMILIES: ["codex"], + saveAccounts: vi.fn(async () => {}), + invalidateAccountManagerCache: vi.fn(), + saveFlaggedAccounts: vi.fn(async () => {}), + showLine, + }); + + expect(queuedRefresh).not.toHaveBeenCalled(); + expect(showLine).toHaveBeenCalledWith( + expect.stringContaining("Missing token: ERROR (missing refreshToken)"), + ); + expect(showLine).toHaveBeenCalledWith( + expect.stringContaining("Blank token: ERROR (missing refreshToken)"), + ); + }); + it("masks emails in output lines", async () => { + const fixedNow = 1_000; const showLine = vi.fn(); await runRuntimeAccountCheck(false, { hydrateEmails: async (storage) => storage, loadAccounts: async () => ({ version: 3, - accounts: [{ email: "visible@example.com", refreshToken: "r1", accessToken: "a1", addedAt: 1, lastUsed: 1, expiresAt: Date.now() + 60_000 }], + accounts: [ + { + email: "visible@example.com", + refreshToken: "r1", + accessToken: "a1", + addedAt: 1, + lastUsed: 1, + expiresAt: fixedNow + 60_000, + }, + ], activeIndex: 0, activeIndexByFamily: { codex: 0 }, }), - createEmptyStorage: () => ({ version: 3, accounts: [], activeIndex: 0, activeIndexByFamily: {} }), + createEmptyStorage, loadFlaggedAccounts: async () => ({ version: 1, accounts: [] }), - createAccountCheckWorkingState: (flaggedStorage) => ({ flaggedStorage, removeFromActive: new Set(), storageChanged: false, flaggedChanged: false, ok: 0, errors: 0, disabled: 0 }), + createAccountCheckWorkingState: (flaggedStorage) => + createWorkingState(flaggedStorage), lookupCodexCliTokensByEmail: async () => null, extractAccountId: () => undefined, shouldUpdateAccountIdFromToken: () => false, @@ -91,7 +423,10 @@ describe("runRuntimeAccountCheck", () => { extractAccountEmail: () => undefined, queuedRefresh: async () => ({ type: "failed", reason: "invalid_grant" }), isRuntimeFlaggableFailure: () => false, - fetchCodexQuotaSnapshot: async () => ({ remaining5h: 1, remaining7d: 2 } as never), + fetchCodexQuotaSnapshot: async () => ({ + remaining5h: 1, + remaining7d: 2, + }) as never, resolveRequestAccountId: () => "acct", formatCodexQuotaLine: () => "quota ok", clampRuntimeActiveIndices: vi.fn(), @@ -99,42 +434,66 @@ describe("runRuntimeAccountCheck", () => { saveAccounts: vi.fn(async () => {}), invalidateAccountManagerCache: vi.fn(), saveFlaggedAccounts: vi.fn(async () => {}), + now: () => fixedNow, showLine, }); - expect(showLine).toHaveBeenCalledWith(expect.stringContaining("vi***@***.com: quota ok")); + expect(showLine).toHaveBeenCalledWith( + expect.stringContaining("vi***@***.com: quota ok"), + ); }); + it("persists flagged storage before saving active accounts", async () => { const calls: string[] = []; await runRuntimeAccountCheck(true, { hydrateEmails: async (storage) => storage, loadAccounts: async () => ({ version: 3, - accounts: [{ email: "one@example.com", refreshToken: "refresh-1", accessToken: undefined, addedAt: 1, lastUsed: 1 }], + accounts: [ + { + email: "one@example.com", + refreshToken: "refresh-1", + accessToken: undefined, + addedAt: 1, + lastUsed: 1, + }, + ], activeIndex: 0, activeIndexByFamily: { codex: 0 }, }), - createEmptyStorage: () => ({ version: 3, accounts: [], activeIndex: 0, activeIndexByFamily: {} }), + createEmptyStorage, loadFlaggedAccounts: async () => ({ version: 1, accounts: [] }), - createAccountCheckWorkingState: (flaggedStorage) => ({ flaggedStorage, removeFromActive: new Set(), storageChanged: false, flaggedChanged: false, ok: 0, errors: 0, disabled: 0 }), + createAccountCheckWorkingState: (flaggedStorage) => + createWorkingState(flaggedStorage), lookupCodexCliTokensByEmail: async () => null, extractAccountId: () => undefined, shouldUpdateAccountIdFromToken: () => false, sanitizeEmail: (email) => email, extractAccountEmail: () => undefined, - queuedRefresh: async () => ({ type: "failed", reason: "invalid_grant", message: "refresh failed" }), + queuedRefresh: async () => ({ + type: "failed", + reason: "invalid_grant", + message: "refresh failed", + }), isRuntimeFlaggableFailure: () => true, - fetchCodexQuotaSnapshot: async () => { throw new Error("should not probe quota in deep mode"); }, + fetchCodexQuotaSnapshot: async () => { + throw new Error("should not probe quota in deep mode"); + }, resolveRequestAccountId: () => undefined, formatCodexQuotaLine: () => "quota", clampRuntimeActiveIndices: vi.fn(), MODEL_FAMILIES: ["codex"], - saveAccounts: vi.fn(async () => { calls.push("saveAccounts"); }), + saveAccounts: vi.fn(async () => { + calls.push("saveAccounts"); + }), invalidateAccountManagerCache: vi.fn(), - saveFlaggedAccounts: vi.fn(async () => { calls.push("saveFlaggedAccounts"); }), + saveFlaggedAccounts: vi.fn(async () => { + calls.push("saveFlaggedAccounts"); + }), showLine: vi.fn(), }); expect(calls).toEqual(["saveFlaggedAccounts", "saveAccounts"]); }); + it("keeps flagged accounts durable when saving active accounts fails", async () => { const saveFlaggedAccounts = vi.fn(async () => {}); const saveAccounts = vi.fn(async () => { @@ -147,21 +506,36 @@ describe("runRuntimeAccountCheck", () => { hydrateEmails: async (storage) => storage, loadAccounts: async () => ({ version: 3, - accounts: [{ email: "one@example.com", refreshToken: "refresh-1", accessToken: undefined, addedAt: 1, lastUsed: 1 }], + accounts: [ + { + email: "one@example.com", + refreshToken: "refresh-1", + accessToken: undefined, + addedAt: 1, + lastUsed: 1, + }, + ], activeIndex: 0, activeIndexByFamily: { codex: 0 }, }), - createEmptyStorage: () => ({ version: 3, accounts: [], activeIndex: 0, activeIndexByFamily: {} }), + createEmptyStorage, loadFlaggedAccounts: async () => ({ version: 1, accounts: [] }), - createAccountCheckWorkingState: (flaggedStorage) => ({ flaggedStorage, removeFromActive: new Set(), storageChanged: false, flaggedChanged: false, ok: 0, errors: 0, disabled: 0 }), + createAccountCheckWorkingState: (flaggedStorage) => + createWorkingState(flaggedStorage), lookupCodexCliTokensByEmail: async () => null, extractAccountId: () => undefined, shouldUpdateAccountIdFromToken: () => false, sanitizeEmail: (email) => email, extractAccountEmail: () => undefined, - queuedRefresh: async () => ({ type: "failed", reason: "invalid_grant", message: "refresh failed" }), + queuedRefresh: async () => ({ + type: "failed", + reason: "invalid_grant", + message: "refresh failed", + }), isRuntimeFlaggableFailure: () => true, - fetchCodexQuotaSnapshot: async () => { throw new Error("should not probe quota in deep mode"); }, + fetchCodexQuotaSnapshot: async () => { + throw new Error("should not probe quota in deep mode"); + }, resolveRequestAccountId: () => undefined, formatCodexQuotaLine: () => "quota", clampRuntimeActiveIndices: vi.fn(), @@ -174,6 +548,8 @@ describe("runRuntimeAccountCheck", () => { ).rejects.toThrow("busy"); expect(saveFlaggedAccounts).toHaveBeenCalledTimes(1); expect(saveAccounts).toHaveBeenCalledTimes(1); - expect(saveFlaggedAccounts.mock.invocationCallOrder[0]).toBeLessThan(saveAccounts.mock.invocationCallOrder[0]); + expect(saveFlaggedAccounts.mock.invocationCallOrder[0]).toBeLessThan( + saveAccounts.mock.invocationCallOrder[0], + ); }); }); diff --git a/test/runtime-auth-facade.test.ts b/test/runtime-auth-facade.test.ts index 9ced6a35..b423fa7c 100644 --- a/test/runtime-auth-facade.test.ts +++ b/test/runtime-auth-facade.test.ts @@ -1,12 +1,16 @@ import { describe, expect, it, vi } from "vitest"; -import { createAccountManagerReloader, createPersistAccounts, runRuntimeOAuthFlow } from "../lib/runtime/auth-facade.js"; +import { + createAccountManagerReloader, + createPersistAccounts, + runRuntimeOAuthFlow, +} from "../lib/runtime/auth-facade.js"; describe("runRuntimeOAuthFlow", () => { it("passes through info logs and prefixes debug/warn logs with the plugin name", async () => { const logInfo = vi.fn(); const logDebug = vi.fn(); const logWarn = vi.fn(); - await runRuntimeOAuthFlow(true, { + const result = await runRuntimeOAuthFlow(true, { runOAuthBrowserFlow: vi.fn(async (input) => { input.logInfo("info message"); input.logDebug("debug message"); @@ -19,6 +23,38 @@ describe("runRuntimeOAuthFlow", () => { logWarn, pluginName: "codex-multi-auth", }); + expect(result).toEqual({ type: "failed", reason: "cancelled" }); + expect(logInfo).toHaveBeenCalledWith("info message"); + expect(logDebug).toHaveBeenCalledWith("[codex-multi-auth] debug message"); + expect(logWarn).toHaveBeenCalledWith("[codex-multi-auth] warn message"); + }); + + it("returns successful oauth results unchanged", async () => { + const logInfo = vi.fn(); + const logDebug = vi.fn(); + const logWarn = vi.fn(); + const successResult = { + type: "success" as const, + access: "access-token", + refresh: "refresh-token", + expires: 1234, + }; + + const result = await runRuntimeOAuthFlow(false, { + runOAuthBrowserFlow: vi.fn(async (input) => { + input.logInfo("info message"); + input.logDebug("debug message"); + input.logWarn("warn message"); + return successResult; + }), + manualModeLabel: "manual", + logInfo, + logDebug, + logWarn, + pluginName: "codex-multi-auth", + }); + + expect(result).toBe(successResult); expect(logInfo).toHaveBeenCalledWith("info message"); expect(logDebug).toHaveBeenCalledWith("[codex-multi-auth] debug message"); expect(logWarn).toHaveBeenCalledWith("[codex-multi-auth] warn message"); @@ -28,18 +64,51 @@ describe("runRuntimeOAuthFlow", () => { describe("createPersistAccounts", () => { it("forwards persist dependencies and replaceAll flag", async () => { const persistAccountPool = vi.fn(async () => {}); - const persistAccounts = createPersistAccounts({ persistAccountPool, withAccountStorageTransaction: vi.fn(), extractAccountId: vi.fn(), extractAccountEmail: vi.fn(), sanitizeEmail: vi.fn(), findMatchingAccountIndex: vi.fn(), MODEL_FAMILIES: ["codex"] }); + const persistAccounts = createPersistAccounts({ + persistAccountPool, + withAccountStorageTransaction: vi.fn(), + extractAccountId: vi.fn(), + extractAccountEmail: vi.fn(), + sanitizeEmail: vi.fn(), + findMatchingAccountIndex: vi.fn(), + MODEL_FAMILIES: ["codex"], + }); const results = [{ refreshToken: "r1" }] as never[]; await persistAccounts(results, true); - expect(persistAccountPool).toHaveBeenCalledWith(results, true, expect.objectContaining({ MODEL_FAMILIES: ["codex"] })); + await persistAccounts(results); + expect(persistAccountPool).toHaveBeenNthCalledWith( + 1, + results, + true, + expect.objectContaining({ MODEL_FAMILIES: ["codex"] }), + ); + expect(persistAccountPool).toHaveBeenNthCalledWith( + 2, + results, + false, + expect.objectContaining({ MODEL_FAMILIES: ["codex"] }), + ); }); }); describe("createAccountManagerReloader", () => { it("forwards auth fallback and current reload state", async () => { const reloadRuntimeAccountManager = vi.fn(async () => "manager"); - const reloader = createAccountManagerReloader({ reloadRuntimeAccountManager, getReloadInFlight: () => null, loadFromDisk: vi.fn(async () => "manager"), setCachedAccountManager: vi.fn(), setAccountManagerPromise: vi.fn(), setReloadInFlight: vi.fn() }); - await expect(reloader({ type: "oauth", access: "a", refresh: "r", expires: 1 })).resolves.toBe("manager"); - expect(reloadRuntimeAccountManager).toHaveBeenCalledWith(expect.objectContaining({ authFallback: expect.objectContaining({ refresh: "r" }) })); + const reloader = createAccountManagerReloader({ + reloadRuntimeAccountManager, + getReloadInFlight: () => null, + loadFromDisk: vi.fn(async () => "manager"), + setCachedAccountManager: vi.fn(), + setAccountManagerPromise: vi.fn(), + setReloadInFlight: vi.fn(), + }); + await expect( + reloader({ type: "oauth", access: "a", refresh: "r", expires: 1 }), + ).resolves.toBe("manager"); + expect(reloadRuntimeAccountManager).toHaveBeenCalledWith( + expect.objectContaining({ + authFallback: expect.objectContaining({ refresh: "r" }), + }), + ); }); }); diff --git a/test/runtime-session-recovery.test.ts b/test/runtime-session-recovery.test.ts index be1ca6c5..16302e6f 100644 --- a/test/runtime-session-recovery.test.ts +++ b/test/runtime-session-recovery.test.ts @@ -1,10 +1,14 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const createSessionRecoveryHookMock = vi.fn(); vi.mock("../lib/recovery.js", () => ({ createSessionRecoveryHook: createSessionRecoveryHookMock, })); +beforeEach(() => { + createSessionRecoveryHookMock.mockClear(); +}); + describe("createRuntimeSessionRecoveryHook", () => { it("returns null when disabled", async () => { const { createRuntimeSessionRecoveryHook } = await import("../lib/runtime/session-recovery.js"); diff --git a/test/runtime-verify-flagged.test.ts b/test/runtime-verify-flagged.test.ts index 7f055dec..ff114fd5 100644 --- a/test/runtime-verify-flagged.test.ts +++ b/test/runtime-verify-flagged.test.ts @@ -3,31 +3,129 @@ import { verifyRuntimeFlaggedAccounts } from "../lib/runtime/verify-flagged.js"; describe("verifyRuntimeFlaggedAccounts", () => { it("restores accounts from Codex CLI cache and preserves the remainder", async () => { + const fixedNow = 1_000; const persistAccounts = vi.fn(async () => {}); const saveFlaggedAccounts = vi.fn(async () => {}); const showLine = vi.fn(); await verifyRuntimeFlaggedAccounts({ - loadFlaggedAccounts: async () => ({ version: 1, accounts: [ { email: "cached@example.com", refreshToken: "cached-refresh", addedAt: 1, lastUsed: 1 }, { email: "flagged@example.com", refreshToken: "flagged-refresh", addedAt: 1, lastUsed: 1 } ] }), - lookupCodexCliTokensByEmail: async (email) => email === "cached@example.com" ? { accessToken: "access", refreshToken: "new-refresh", expiresAt: Date.now() + 60000 } : null, - queuedRefresh: async () => ({ type: "failed", reason: "invalid_grant", message: "refresh failed" }), - resolveAccountSelection: (tokens) => ({ refreshToken: tokens.refresh, accessToken: tokens.access }) as never, + loadFlaggedAccounts: async () => ({ + version: 1, + accounts: [ + { + email: "cached@example.com", + refreshToken: "cached-refresh", + addedAt: 1, + lastUsed: 1, + }, + { + email: "flagged@example.com", + refreshToken: "flagged-refresh", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + lookupCodexCliTokensByEmail: async (email) => + email === "cached@example.com" + ? { + accessToken: "access", + refreshToken: "new-refresh", + expiresAt: fixedNow + 60_000, + } + : null, + queuedRefresh: async () => ({ + type: "failed", + reason: "invalid_grant", + message: "refresh failed", + }), + resolveAccountSelection: (tokens) => + ({ refreshToken: tokens.refresh, accessToken: tokens.access }) as never, persistAccounts, invalidateAccountManagerCache: vi.fn(), saveFlaggedAccounts, logInfo: vi.fn(), + now: () => fixedNow, showLine, }); expect(persistAccounts).toHaveBeenCalledTimes(1); - expect(saveFlaggedAccounts).toHaveBeenCalledWith({ version: 1, accounts: [expect.objectContaining({ refreshToken: "flagged-refresh" })] }); - expect(showLine).toHaveBeenCalledWith(expect.stringContaining("ca***@***.com: RESTORED (Codex CLI cache)")); + expect(saveFlaggedAccounts).toHaveBeenCalledWith({ + version: 1, + accounts: [expect.objectContaining({ refreshToken: "flagged-refresh" })], + }); + expect(showLine).toHaveBeenCalledWith( + expect.stringContaining("ca***@***.com: RESTORED (Codex CLI cache)"), + ); + }); + + it("restores accounts after a successful refresh", async () => { + const persistAccounts = vi.fn(async () => {}); + const invalidateAccountManagerCache = vi.fn(); + const saveFlaggedAccounts = vi.fn(async () => {}); + const showLine = vi.fn(); + await verifyRuntimeFlaggedAccounts({ + loadFlaggedAccounts: async () => ({ + version: 1, + accounts: [ + { + email: "restored@example.com", + refreshToken: "old-refresh", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + lookupCodexCliTokensByEmail: async () => null, + queuedRefresh: async () => ({ + type: "success", + access: "new-access", + refresh: "new-refresh", + expires: 2_000, + }), + resolveAccountSelection: (tokens) => + ({ refreshToken: tokens.refresh, accessToken: tokens.access }) as never, + persistAccounts, + invalidateAccountManagerCache, + saveFlaggedAccounts, + logInfo: vi.fn(), + showLine, + }); + expect(persistAccounts).toHaveBeenCalledWith( + [ + expect.objectContaining({ + refreshToken: "new-refresh", + accessToken: "new-access", + }), + ], + false, + ); + expect(invalidateAccountManagerCache).toHaveBeenCalledTimes(1); + expect(saveFlaggedAccounts).toHaveBeenCalledWith({ + version: 1, + accounts: [], + }); + expect(showLine).toHaveBeenCalledWith( + expect.stringContaining("re***@***.com: RESTORED"), + ); }); it("logs verification failures through logError and keeps the account flagged", async () => { const logError = vi.fn(); const saveFlaggedAccounts = vi.fn(async () => {}); await verifyRuntimeFlaggedAccounts({ - loadFlaggedAccounts: async () => ({ version: 1, accounts: [{ email: "broken@example.com", refreshToken: "broken-refresh", addedAt: 1, lastUsed: 1 }] }), - lookupCodexCliTokensByEmail: async () => { throw new Error("cache unavailable"); }, + loadFlaggedAccounts: async () => ({ + version: 1, + accounts: [ + { + email: "broken@example.com", + refreshToken: "broken-refresh", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + lookupCodexCliTokensByEmail: async () => { + throw new Error("cache unavailable"); + }, queuedRefresh: async () => ({ type: "failed", reason: "invalid_grant" }), resolveAccountSelection: () => ({}) as never, persistAccounts: vi.fn(async () => {}), @@ -37,7 +135,19 @@ describe("verifyRuntimeFlaggedAccounts", () => { logError, showLine: vi.fn(), }); - expect(logError).toHaveBeenCalledWith(expect.stringContaining("Failed to verify flagged account br***@***.com: cache unavailable")); - expect(saveFlaggedAccounts).toHaveBeenCalledWith({ version: 1, accounts: [expect.objectContaining({ refreshToken: "broken-refresh", lastError: "cache unavailable" })] }); + expect(logError).toHaveBeenCalledWith( + expect.stringContaining( + "Failed to verify flagged account br***@***.com: cache unavailable", + ), + ); + expect(saveFlaggedAccounts).toHaveBeenCalledWith({ + version: 1, + accounts: [ + expect.objectContaining({ + refreshToken: "broken-refresh", + lastError: "cache unavailable", + }), + ], + }); }); }); From 72a11b85bed1afbc69d97d2881a55d924ac55b75 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:31:41 +0800 Subject: [PATCH 289/376] test: cover bench format renderer --- test/bench-format-render.test.ts | 47 ++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 test/bench-format-render.test.ts diff --git a/test/bench-format-render.test.ts b/test/bench-format-render.test.ts new file mode 100644 index 00000000..1312f523 --- /dev/null +++ b/test/bench-format-render.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { + buildMarkdownReport, + renderDashboardHtml, +} from "../scripts/bench-format/render.mjs"; + +const summary = { + meta: { + generatedAt: "2026-03-22T00:00:00.000Z", + preset: "codex-core", + models: ["gpt-5-codex"], + tasks: ["task-1"], + modes: ["patch", "replace", "hashline", "hashline_v2"], + runCount: 1, + warmupCount: 0, + }, + rows: [ + { + modelId: "gpt-5-codex", + displayName: "GPT-5 Codex", + modes: { + patch: { accuracyPct: 90, wallMsP50: 1000, tokensTotalP50: 100 }, + replace: { accuracyPct: 85, wallMsP50: 1100, tokensTotalP50: 90 }, + hashline: { accuracyPct: 88, wallMsP50: 1050, tokensTotalP50: 95 }, + hashline_v2: { accuracyPct: 92, wallMsP50: 980, tokensTotalP50: 80 }, + }, + }, + ], + failures: [], +}; + +describe("bench format renderer", () => { + it("builds markdown report with leaderboard content", () => { + const markdown = buildMarkdownReport(summary as never); + expect(markdown).toContain("# Code Edit Format Benchmark"); + expect(markdown).toContain("## Leaderboard (Accuracy First)"); + expect(markdown).toContain("GPT-5 Codex"); + }); + + it("renders dashboard html with embedded model data", () => { + const html = renderDashboardHtml(summary as never); + expect(html).toContain(""); + expect(html).toContain("Code Edit Format Benchmark"); + expect(html).toContain("GPT-5 Codex"); + expect(html).toContain("deltaVsReplaceHashline"); + }); +}); From c95f3161c9d8a30e91495c4e08fdb80ebd358b16 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:39:59 +0800 Subject: [PATCH 290/376] Fix runtime review follow-ups --- lib/runtime/account-check.ts | 7 --- scripts/check-pack-budget.mjs | 4 +- test/runtime-account-check.test.ts | 86 ++++++++++++++++++++++++-- test/runtime-auth-facade.test.ts | 88 +++++++++++++++++---------- test/runtime-session-recovery.test.ts | 6 +- test/runtime-verify-flagged.test.ts | 28 ++++++--- 6 files changed, 165 insertions(+), 54 deletions(-) diff --git a/lib/runtime/account-check.ts b/lib/runtime/account-check.ts index dfc2bc8d..01236c95 100644 --- a/lib/runtime/account-check.ts +++ b/lib/runtime/account-check.ts @@ -137,13 +137,6 @@ export async function runRuntimeAccountCheck( const cached = await deps .lookupCodexCliTokensByEmail(account.email) .catch(() => null); - if ( - cached?.refreshToken && - cached.refreshToken !== account.refreshToken - ) { - account.refreshToken = cached.refreshToken; - state.storageChanged = true; - } if ( cached && (typeof cached.expiresAt !== "number" || diff --git a/scripts/check-pack-budget.mjs b/scripts/check-pack-budget.mjs index eb32a6ff..20c324d9 100644 --- a/scripts/check-pack-budget.mjs +++ b/scripts/check-pack-budget.mjs @@ -5,7 +5,7 @@ import { promisify } from "node:util"; const execAsync = promisify(exec); const MAX_PACKAGE_SIZE = 8 * 1024 * 1024; -const REQUIRED_PREFIXES = [ +const REQUIRED_PATHS = [ "dist/", "assets/", "config/", @@ -68,7 +68,7 @@ for (const forbidden of FORBIDDEN_PREFIXES) { } } -for (const required of REQUIRED_PREFIXES) { +for (const required of REQUIRED_PATHS) { const present = paths.some( (/** @type {string} */ path) => path === required || path.startsWith(required), ); diff --git a/test/runtime-account-check.test.ts b/test/runtime-account-check.test.ts index f62f4b55..9a3b9617 100644 --- a/test/runtime-account-check.test.ts +++ b/test/runtime-account-check.test.ts @@ -103,8 +103,16 @@ describe("runRuntimeAccountCheck", () => { }); expect(calls).toEqual(["saveFlaggedAccounts", "saveAccounts"]); }); - it("promotes a newer cached refresh token even when cached access is expired", async () => { + it("keeps the stored refresh token when the CLI cache is expired", async () => { const saveAccounts = vi.fn(async () => {}); + const queuedRefresh = vi.fn( + async (refreshToken: string) => ({ + type: "success" as const, + access: "new-access", + refresh: "rotated-refresh", + expires: 70_000, + }), + ); await runRuntimeAccountCheck(false, { hydrateEmails: async (storage) => storage, loadAccounts: async () => ({ @@ -116,12 +124,12 @@ describe("runRuntimeAccountCheck", () => { createEmptyStorage: () => ({ version: 3, accounts: [], activeIndex: 0, activeIndexByFamily: {} }), loadFlaggedAccounts: async () => ({ version: 1, accounts: [] }), createAccountCheckWorkingState: (flaggedStorage) => ({ flaggedStorage, removeFromActive: new Set(), storageChanged: false, flaggedChanged: false, ok: 0, errors: 0, disabled: 0 }), - lookupCodexCliTokensByEmail: async () => ({ accessToken: "expired-access", refreshToken: "fresh-refresh", expiresAt: Date.now() - 1 }), + lookupCodexCliTokensByEmail: async () => ({ accessToken: "expired-access", refreshToken: "fresh-refresh", expiresAt: 9_999 }), extractAccountId: () => undefined, shouldUpdateAccountIdFromToken: () => false, sanitizeEmail: (email) => email, extractAccountEmail: () => undefined, - queuedRefresh: async (refreshToken) => ({ type: "success", access: "new-access", refresh: refreshToken, expires: Date.now() + 60_000 }), + queuedRefresh, isRuntimeFlaggableFailure: () => false, fetchCodexQuotaSnapshot: async () => ({ remaining5h: 1, remaining7d: 2 } as never), resolveRequestAccountId: () => "acct", @@ -131,10 +139,80 @@ describe("runRuntimeAccountCheck", () => { saveAccounts, invalidateAccountManagerCache: vi.fn(), saveFlaggedAccounts: vi.fn(async () => {}), + now: () => 10_000, + showLine: vi.fn(), + }); + expect(queuedRefresh).toHaveBeenCalledWith("stale-refresh"); + const saved = saveAccounts.mock.calls[0]?.[0]; + expect(saved.accounts[0]?.refreshToken).toBe("rotated-refresh"); + }); + + it("hydrates account state from a valid CLI cache entry without refreshing", async () => { + const queuedRefresh = vi.fn(async () => ({ type: "failed" as const, reason: "invalid_grant" })); + const fetchCodexQuotaSnapshot = vi.fn( + async () => ({ remaining5h: 1, remaining7d: 2 } as never), + ); + const saveAccounts = vi.fn(async () => {}); + const invalidateAccountManagerCache = vi.fn(); + await runRuntimeAccountCheck(false, { + hydrateEmails: async (storage) => storage, + loadAccounts: async () => ({ + version: 3, + accounts: [ + { + email: "old@example.com", + refreshToken: "stale-refresh", + accessToken: undefined, + accountId: "old-account", + accountIdSource: "manual", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }), + createEmptyStorage: () => ({ version: 3, accounts: [], activeIndex: 0, activeIndexByFamily: {} }), + loadFlaggedAccounts: async () => ({ version: 1, accounts: [] }), + createAccountCheckWorkingState: (flaggedStorage) => ({ flaggedStorage, removeFromActive: new Set(), storageChanged: false, flaggedChanged: false, ok: 0, errors: 0, disabled: 0 }), + lookupCodexCliTokensByEmail: async () => ({ + accessToken: "cached-access", + refreshToken: "fresh-refresh", + expiresAt: 70_000, + }), + extractAccountId: () => "new-account", + shouldUpdateAccountIdFromToken: () => true, + sanitizeEmail: (email) => email, + extractAccountEmail: () => "fresh@example.com", + queuedRefresh, + isRuntimeFlaggableFailure: () => false, + fetchCodexQuotaSnapshot, + resolveRequestAccountId: () => "resolved-account", + formatCodexQuotaLine: () => "quota ok", + clampRuntimeActiveIndices: vi.fn(), + MODEL_FAMILIES: ["codex"], + saveAccounts, + invalidateAccountManagerCache, + saveFlaggedAccounts: vi.fn(async () => {}), + now: () => 10_000, showLine: vi.fn(), }); + expect(queuedRefresh).not.toHaveBeenCalled(); + expect(fetchCodexQuotaSnapshot).toHaveBeenCalledWith({ + accountId: "resolved-account", + accessToken: "cached-access", + }); + expect(saveAccounts).toHaveBeenCalledTimes(1); + expect(invalidateAccountManagerCache).toHaveBeenCalledTimes(1); const saved = saveAccounts.mock.calls[0]?.[0]; - expect(saved.accounts[0]?.refreshToken).toBe("fresh-refresh"); + expect(saved.accounts[0]).toMatchObject({ + email: "fresh@example.com", + refreshToken: "fresh-refresh", + accessToken: "cached-access", + expiresAt: 70_000, + accountId: "new-account", + accountIdSource: "token", + }); }); it("treats cache lookup failures as a cache miss and still refreshes", async () => { diff --git a/test/runtime-auth-facade.test.ts b/test/runtime-auth-facade.test.ts index f594018c..677057b1 100644 --- a/test/runtime-auth-facade.test.ts +++ b/test/runtime-auth-facade.test.ts @@ -6,53 +6,53 @@ import { } from "../lib/runtime/auth-facade.js"; describe("runRuntimeOAuthFlow", () => { - it("passes through info logs and prefixes debug/warn logs with the plugin name", async () => { + it("passes through the flow config and prefixes debug/warn logs with the plugin name", async () => { + const runOAuthBrowserFlow = vi.fn(async (input) => { + input.logInfo("info message"); + input.logDebug("debug message"); + input.logWarn("warn message"); + return { type: "failed" as const, reason: "cancelled" }; + }); const logInfo = vi.fn(); const logDebug = vi.fn(); const logWarn = vi.fn(); - await runRuntimeOAuthFlow(true, { - runOAuthBrowserFlow: vi.fn(async (input) => { - input.logInfo("info message"); - input.logDebug("debug message"); - input.logWarn("warn message"); - return { type: "failed", reason: "cancelled" }; + await expect( + runRuntimeOAuthFlow(true, { + runOAuthBrowserFlow, + manualModeLabel: "manual", + logInfo, + logDebug, + logWarn, + pluginName: "codex-multi-auth", }), - manualModeLabel: "manual", - logInfo, - logDebug, - logWarn, - pluginName: "codex-multi-auth", - }); + ).resolves.toEqual({ type: "failed", reason: "cancelled" }); + expect(runOAuthBrowserFlow).toHaveBeenCalledWith( + expect.objectContaining({ + forceNewLogin: true, + manualModeLabel: "manual", + }), + ); expect(logInfo).toHaveBeenCalledWith("info message"); expect(logDebug).toHaveBeenCalledWith("[codex-multi-auth] debug message"); expect(logWarn).toHaveBeenCalledWith("[codex-multi-auth] warn message"); }); - it("returns existing reload promise when one is in flight", async () => { - const reloadRuntimeAccountManager = vi.fn(async () => "manager"); - const inFlight = Promise.resolve("existing-manager"); - const reloader = createAccountManagerReloader({ - reloadRuntimeAccountManager, - getReloadInFlight: () => inFlight as Promise, - loadFromDisk: vi.fn(async () => "manager"), - setCachedAccountManager: vi.fn(), - setAccountManagerPromise: vi.fn(), - setReloadInFlight: vi.fn(), - }); - await expect(reloader()).resolves.toBe("existing-manager"); - expect(reloadRuntimeAccountManager).not.toHaveBeenCalled(); - }); }); describe("createPersistAccounts", () => { it("forwards persist dependencies and replaceAll flag", async () => { + const withAccountStorageTransaction = vi.fn(); + const extractAccountId = vi.fn(); + const extractAccountEmail = vi.fn(); + const sanitizeEmail = vi.fn(); + const findMatchingAccountIndex = vi.fn(); const persistAccountPool = vi.fn(async () => {}); const persistAccounts = createPersistAccounts({ persistAccountPool, - withAccountStorageTransaction: vi.fn(), - extractAccountId: vi.fn(), - extractAccountEmail: vi.fn(), - sanitizeEmail: vi.fn(), - findMatchingAccountIndex: vi.fn(), + withAccountStorageTransaction, + extractAccountId, + extractAccountEmail, + sanitizeEmail, + findMatchingAccountIndex, MODEL_FAMILIES: ["codex"], }); const results = [{ refreshToken: "r1" }] as never[]; @@ -60,7 +60,14 @@ describe("createPersistAccounts", () => { expect(persistAccountPool).toHaveBeenCalledWith( results, true, - expect.objectContaining({ MODEL_FAMILIES: ["codex"] }), + { + withAccountStorageTransaction, + extractAccountId, + extractAccountEmail, + sanitizeEmail, + findMatchingAccountIndex, + MODEL_FAMILIES: ["codex"], + }, ); }); it("keeps replaceAll optional and false by default", async () => { @@ -84,6 +91,21 @@ describe("createPersistAccounts", () => { }); describe("createAccountManagerReloader", () => { + it("returns existing reload promise when one is in flight", async () => { + const reloadRuntimeAccountManager = vi.fn(async () => "manager"); + const inFlight = Promise.resolve("existing-manager"); + const reloader = createAccountManagerReloader({ + reloadRuntimeAccountManager, + getReloadInFlight: () => inFlight as Promise, + loadFromDisk: vi.fn(async () => "manager"), + setCachedAccountManager: vi.fn(), + setAccountManagerPromise: vi.fn(), + setReloadInFlight: vi.fn(), + }); + await expect(reloader()).resolves.toBe("existing-manager"); + expect(reloadRuntimeAccountManager).not.toHaveBeenCalled(); + }); + it("forwards auth fallback and current reload state", async () => { const reloadRuntimeAccountManager = vi.fn(async () => "manager"); const reloader = createAccountManagerReloader({ diff --git a/test/runtime-session-recovery.test.ts b/test/runtime-session-recovery.test.ts index be1ca6c5..2d625f8e 100644 --- a/test/runtime-session-recovery.test.ts +++ b/test/runtime-session-recovery.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const createSessionRecoveryHookMock = vi.fn(); vi.mock("../lib/recovery.js", () => ({ @@ -6,6 +6,10 @@ vi.mock("../lib/recovery.js", () => ({ })); describe("createRuntimeSessionRecoveryHook", () => { + beforeEach(() => { + createSessionRecoveryHookMock.mockClear(); + }); + it("returns null when disabled", async () => { const { createRuntimeSessionRecoveryHook } = await import("../lib/runtime/session-recovery.js"); expect( diff --git a/test/runtime-verify-flagged.test.ts b/test/runtime-verify-flagged.test.ts index 469534f4..de435b98 100644 --- a/test/runtime-verify-flagged.test.ts +++ b/test/runtime-verify-flagged.test.ts @@ -18,8 +18,10 @@ describe("verifyRuntimeFlaggedAccounts", () => { expect(showLine).toHaveBeenCalledWith("\nNo flagged accounts to verify.\n"); }); it("restores accounts from Codex CLI cache and preserves the remainder", async () => { + const now = 10_000; const persistAccounts = vi.fn(async () => {}); const saveFlaggedAccounts = vi.fn(async () => {}); + const invalidateAccountManagerCache = vi.fn(); const showLine = vi.fn(); await verifyRuntimeFlaggedAccounts({ loadFlaggedAccounts: async () => ({ @@ -31,17 +33,19 @@ describe("verifyRuntimeFlaggedAccounts", () => { }), lookupCodexCliTokensByEmail: async (email) => email === "cached@example.com" - ? { accessToken: "access", refreshToken: "new-refresh", expiresAt: Date.now() + 60_000 } + ? { accessToken: "access", refreshToken: "new-refresh", expiresAt: now + 60_000 } : null, queuedRefresh: async () => ({ type: "failed", reason: "invalid_grant", message: "refresh failed" }), resolveAccountSelection: (tokens) => ({ refreshToken: tokens.refresh, accessToken: tokens.access }) as never, persistAccounts, - invalidateAccountManagerCache: vi.fn(), + invalidateAccountManagerCache, saveFlaggedAccounts, logInfo: vi.fn(), + now: () => now, showLine, }); expect(persistAccounts).toHaveBeenCalledTimes(1); + expect(invalidateAccountManagerCache).toHaveBeenCalledTimes(1); expect(saveFlaggedAccounts).toHaveBeenCalledWith({ version: 1, accounts: [expect.objectContaining({ refreshToken: "flagged-refresh" })], @@ -50,31 +54,36 @@ describe("verifyRuntimeFlaggedAccounts", () => { }); it("restores accounts after a successful refresh when cache misses", async () => { + const now = 10_000; const persistAccounts = vi.fn(async () => {}); const saveFlaggedAccounts = vi.fn(async () => {}); + const invalidateAccountManagerCache = vi.fn(); const showLine = vi.fn(); await verifyRuntimeFlaggedAccounts({ loadFlaggedAccounts: async () => ({ version: 1, accounts: [{ email: "refresh@example.com", refreshToken: "refresh-token", addedAt: 1, lastUsed: 1 }] }), lookupCodexCliTokensByEmail: async () => null, - queuedRefresh: async () => ({ type: "success", access: "new-access", refresh: "new-refresh", expires: Date.now() + 60_000 }), + queuedRefresh: async () => ({ type: "success", access: "new-access", refresh: "new-refresh", expires: now + 60_000 }), resolveAccountSelection: (tokens) => ({ refreshToken: tokens.refresh, accessToken: tokens.access }) as never, persistAccounts, - invalidateAccountManagerCache: vi.fn(), + invalidateAccountManagerCache, saveFlaggedAccounts, logInfo: vi.fn(), + now: () => now, showLine, }); expect(persistAccounts).toHaveBeenCalledWith([expect.objectContaining({ refreshToken: "new-refresh", accessToken: "new-access" })], false); + expect(invalidateAccountManagerCache).toHaveBeenCalledTimes(1); expect(saveFlaggedAccounts).toHaveBeenCalledWith({ version: 1, accounts: [] }); expect(showLine).toHaveBeenCalledWith(expect.stringContaining("re***@***.com: RESTORED")); }); it("logs verification failures through logError and keeps the account flagged", async () => { + const now = 10_000; const logError = vi.fn(); const saveFlaggedAccounts = vi.fn(async () => {}); await verifyRuntimeFlaggedAccounts({ loadFlaggedAccounts: async () => ({ version: 1, accounts: [{ email: "broken@example.com", refreshToken: "broken-refresh", addedAt: 1, lastUsed: 1 }] }), - lookupCodexCliTokensByEmail: async () => ({ accessToken: "cached-access", refreshToken: "cached-refresh", expiresAt: Date.now() + 60_000 }), + lookupCodexCliTokensByEmail: async () => ({ accessToken: "cached-access", refreshToken: "cached-refresh", expiresAt: now + 60_000 }), queuedRefresh: async () => ({ type: "failed", reason: "invalid_grant" }), resolveAccountSelection: () => { throw new Error("selection failed"); }, persistAccounts: vi.fn(async () => {}), @@ -82,6 +91,7 @@ describe("verifyRuntimeFlaggedAccounts", () => { saveFlaggedAccounts, logInfo: vi.fn(), logError, + now: () => now, showLine: vi.fn(), }); expect(logError).toHaveBeenCalledWith(expect.stringContaining("Failed to verify flagged account br***@***.com: selection failed")); @@ -91,22 +101,25 @@ describe("verifyRuntimeFlaggedAccounts", () => { }); }); it("writes restored accounts before flagged state cleanup", async () => { + const now = 10_000; const calls: string[] = []; await verifyRuntimeFlaggedAccounts({ loadFlaggedAccounts: async () => ({ version: 1, accounts: [{ email: "refresh@example.com", refreshToken: "refresh-token", addedAt: 1, lastUsed: 1 }] }), lookupCodexCliTokensByEmail: async () => { throw new Error("busy"); }, - queuedRefresh: async () => ({ type: "success", access: "new-access", refresh: "new-refresh", expires: Date.now() + 60_000 }), + queuedRefresh: async () => ({ type: "success", access: "new-access", refresh: "new-refresh", expires: now + 60_000 }), resolveAccountSelection: (tokens) => ({ refreshToken: tokens.refresh, accessToken: tokens.access }) as never, persistAccounts: vi.fn(async () => { calls.push("persistAccounts"); }), invalidateAccountManagerCache: vi.fn(), saveFlaggedAccounts: vi.fn(async () => { calls.push("saveFlaggedAccounts"); }), logInfo: vi.fn(), + now: () => now, showLine: vi.fn(), }); expect(calls).toEqual(["persistAccounts", "saveFlaggedAccounts"]); }); it("leaves flagged state untouched when persistAccounts throws EBUSY", async () => { + const now = 10_000; const saveFlaggedAccounts = vi.fn(async () => {}); const persistAccounts = vi.fn(async () => { const error = new Error("busy") as Error & { code?: string }; @@ -117,12 +130,13 @@ describe("verifyRuntimeFlaggedAccounts", () => { verifyRuntimeFlaggedAccounts({ loadFlaggedAccounts: async () => ({ version: 1, accounts: [{ email: "refresh@example.com", refreshToken: "refresh-token", addedAt: 1, lastUsed: 1 }] }), lookupCodexCliTokensByEmail: async () => { throw new Error("busy"); }, - queuedRefresh: async () => ({ type: "success", access: "new-access", refresh: "new-refresh", expires: Date.now() + 60_000 }), + queuedRefresh: async () => ({ type: "success", access: "new-access", refresh: "new-refresh", expires: now + 60_000 }), resolveAccountSelection: (tokens) => ({ refreshToken: tokens.refresh, accessToken: tokens.access }) as never, persistAccounts, invalidateAccountManagerCache: vi.fn(), saveFlaggedAccounts, logInfo: vi.fn(), + now: () => now, showLine: vi.fn(), }), ).rejects.toThrow("busy"); From 9095badc187fb69d91dc9222d249e11842a550e9 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:42:01 +0800 Subject: [PATCH 291/376] docs: document supported package subpaths --- docs/reference/public-api.md | 6 ++++++ test/documentation.test.ts | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md index 865189ff..989b0104 100644 --- a/docs/reference/public-api.md +++ b/docs/reference/public-api.md @@ -16,6 +16,12 @@ Stable APIs are covered by semver compatibility guarantees and must remain backw - `OpenAIOAuthPlugin` - `OpenAIAuthPlugin` - default export (alias of `OpenAIOAuthPlugin`) +- Supported package subpath entrypoints: + - `codex-multi-auth/auth` + - `codex-multi-auth/storage` + - `codex-multi-auth/config` + - `codex-multi-auth/request` + - `codex-multi-auth/cli` - CLI surface: - `codex auth ...` command family - documented flags and aliases in `reference/commands.md` diff --git a/test/documentation.test.ts b/test/documentation.test.ts index 1c696d36..7241c40c 100644 --- a/test/documentation.test.ts +++ b/test/documentation.test.ts @@ -224,6 +224,11 @@ describe("Documentation Integrity", () => { expect(publicApi).toContain("tier c"); expect(publicApi).toContain("options-object"); expect(publicApi).toContain("semver"); + expect(publicApi).toContain("codex-multi-auth/auth"); + expect(publicApi).toContain("codex-multi-auth/storage"); + expect(publicApi).toContain("codex-multi-auth/config"); + expect(publicApi).toContain("codex-multi-auth/request"); + expect(publicApi).toContain("codex-multi-auth/cli"); expect(errorContracts).toContain("exit codes"); expect(errorContracts).toContain("json mode contract"); From f335beb98e0a239b86283146e53439a228d1a485 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:48:05 +0800 Subject: [PATCH 292/376] Hide internal request SSE helpers --- lib/request/index.ts | 2 -- test/public-api-contract.test.ts | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/request/index.ts b/lib/request/index.ts index 076c0643..869bad70 100644 --- a/lib/request/index.ts +++ b/lib/request/index.ts @@ -2,5 +2,3 @@ export * from "./failure-policy.js"; export * from "./fetch-helpers.js"; export * from "./rate-limit-backoff.js"; export * from "./request-transformer.js"; -export * from "./response-handler.js"; -export * from "./stream-failover.js"; diff --git a/test/public-api-contract.test.ts b/test/public-api-contract.test.ts index f81e60ae..3e6ceaf7 100644 --- a/test/public-api-contract.test.ts +++ b/test/public-api-contract.test.ts @@ -96,6 +96,8 @@ describe("public api contract", () => { expect(typeof config.loadPluginConfig).toBe("function"); expect(typeof request.createCodexHeaders).toBe("function"); expect(typeof request.transformRequestBody).toBe("function"); + expect("handleResponse" in request).toBe(false); + expect("withStreamFailover" in request).toBe(false); expect(typeof cli.loadCodexCliState).toBe("function"); }); From 95409852e0005f1f65c5b0ddc2c075eda23f13fa Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:50:57 +0800 Subject: [PATCH 293/376] Deduplicate settings fetch status docs --- docs/reference/settings.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/reference/settings.md b/docs/reference/settings.md index 035a3b32..d6cd63d2 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -75,7 +75,6 @@ Controls result-screen return behavior and menu quota refresh behavior. | `actionAutoReturnMs` | `2000` | Delay before returning from action/result screens | | `actionPauseOnKey` | `true` | Pause on keypress before auto-return completes | | `menuAutoFetchLimits` | `true` | Refresh quota snapshots automatically in the menu | -| `menuShowFetchStatus` | `true` | Show fetch status text while quota refresh is running | | `menuQuotaTtlMs` | `300000` | Reuse cached quota data before refetching | ## Color Theme From dd90b77592bb534c0965ae7d59437b5b64568d7a Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:52:21 +0800 Subject: [PATCH 294/376] Add auth log redaction QA note --- docs/development/RUNBOOK_ADD_AUTH_MANAGER_COMMAND.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/development/RUNBOOK_ADD_AUTH_MANAGER_COMMAND.md b/docs/development/RUNBOOK_ADD_AUTH_MANAGER_COMMAND.md index 2740fc20..2c8662d6 100644 --- a/docs/development/RUNBOOK_ADD_AUTH_MANAGER_COMMAND.md +++ b/docs/development/RUNBOOK_ADD_AUTH_MANAGER_COMMAND.md @@ -34,4 +34,5 @@ Add a command without breaking the existing CLI surface, help text, JSON mode, o - `npm run typecheck` - `npm run lint -- lib/codex-manager.ts test/codex-manager-cli.test.ts docs/reference/commands.md test/documentation.test.ts` - `npm run test -- test/codex-manager-cli.test.ts test/documentation.test.ts` +- For auth flows, never paste raw tokens/session headers in PRs, issues, or logs; redact sensitive output. - Run the real command or `--help` path in Bash and inspect output From f3b55a284fa28c36dcf914fad7906e7a669dab8c Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:53:33 +0800 Subject: [PATCH 295/376] Use retry-safe cleanup in benchmark path test --- test/benchmark-runtime-path-script.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/benchmark-runtime-path-script.test.ts b/test/benchmark-runtime-path-script.test.ts index 5e215139..6e727f22 100644 --- a/test/benchmark-runtime-path-script.test.ts +++ b/test/benchmark-runtime-path-script.test.ts @@ -4,20 +4,22 @@ import { mkdirSync, mkdtempSync, readFileSync, - rmSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { removeWithRetry } from "./helpers/remove-with-retry.js"; const tempRoots: string[] = []; const scriptPath = "scripts/benchmark-runtime-path.mjs"; -afterEach(() => { +afterEach(async () => { while (tempRoots.length > 0) { const root = tempRoots.pop(); - if (root) rmSync(root, { recursive: true, force: true }); + if (root) { + await removeWithRetry(root, { recursive: true, force: true }); + } } }); From bf626e6eae0b8eff33bcbd32a8f59a63f9ff4e52 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:54:53 +0800 Subject: [PATCH 296/376] Cover settings hub cancel passthrough --- test/settings-hub-entry.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/settings-hub-entry.test.ts b/test/settings-hub-entry.test.ts index 1d3b1413..e30fc167 100644 --- a/test/settings-hub-entry.test.ts +++ b/test/settings-hub-entry.test.ts @@ -34,4 +34,23 @@ describe("settings hub entry", () => { ); expect(result).toEqual({ type: "back" }); }); + + it("passes through null when the settings hub prompt is cancelled", async () => { + const promptSettingsHubMenu = vi.fn(async () => null); + const select = vi.fn(); + + const result = await promptSettingsHubEntry({ + initialFocus: "account-list", + promptSettingsHubMenu, + isInteractive: () => true, + getUiRuntimeOptions: vi.fn(() => ({ theme: {} }) as never), + buildItems: vi.fn(() => []), + findInitialCursor: vi.fn(() => 0), + select, + copy: { title: "Settings", subtitle: "Subtitle", help: "Help" }, + }); + + expect(promptSettingsHubMenu).toHaveBeenCalledOnce(); + expect(result).toBeNull(); + }); }); From 510f481298d9e686cd010cfe84e045e14b2f1313 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:55:58 +0800 Subject: [PATCH 297/376] test: cover codex-multi-auth wrapper --- test/codex-multi-auth-wrapper.test.ts | 68 +++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 test/codex-multi-auth-wrapper.test.ts diff --git a/test/codex-multi-auth-wrapper.test.ts b/test/codex-multi-auth-wrapper.test.ts new file mode 100644 index 00000000..84c9f65f --- /dev/null +++ b/test/codex-multi-auth-wrapper.test.ts @@ -0,0 +1,68 @@ +import { spawnSync } from "node:child_process"; +import { + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const tempRoots: string[] = []; + +afterEach(() => { + while (tempRoots.length > 0) { + const root = tempRoots.pop(); + if (root) rmSync(root, { recursive: true, force: true }); + } +}); + +describe("codex-multi-auth wrapper", () => { + it("loads the built CLI entry and forwards args with package version env", () => { + const root = mkdtempSync(path.join(tmpdir(), "codex-multi-auth-wrapper-")); + tempRoots.push(root); + const scriptsDir = path.join(root, "scripts"); + const distLibDir = path.join(root, "dist", "lib"); + mkdirSync(scriptsDir, { recursive: true }); + mkdirSync(distLibDir, { recursive: true }); + + copyFileSync( + path.join(process.cwd(), "scripts", "codex-multi-auth.js"), + path.join(scriptsDir, "codex-multi-auth.js"), + ); + writeFileSync( + path.join(root, "package.json"), + JSON.stringify({ version: "9.9.9-test" }, null, 2), + "utf8", + ); + writeFileSync( + path.join(distLibDir, "codex-manager.js"), + [ + "export async function runCodexMultiAuthCli(args) {", + " console.log('version=' + process.env.CODEX_MULTI_AUTH_CLI_VERSION);", + " console.log('args=' + args.join(' '));", + " return 0;", + "}", + ].join("\n"), + "utf8", + ); + + const result = spawnSync( + process.execPath, + [path.join(scriptsDir, "codex-multi-auth.js"), "auth", "--help"], + { cwd: root, encoding: "utf8" }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("version=9.9.9-test"); + expect(result.stdout).toContain("args=auth --help"); + const scriptText = readFileSync( + path.join(scriptsDir, "codex-multi-auth.js"), + "utf8", + ); + expect(scriptText).toContain("runCodexMultiAuthCli"); + }); +}); From 37a9f2fcb1a7a1a03b29b05dea1ff79b27b146b7 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:59:15 +0800 Subject: [PATCH 298/376] Drop unused benchmark path import --- test/benchmark-runtime-path-script.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/benchmark-runtime-path-script.test.ts b/test/benchmark-runtime-path-script.test.ts index 6e727f22..90f6d28b 100644 --- a/test/benchmark-runtime-path-script.test.ts +++ b/test/benchmark-runtime-path-script.test.ts @@ -7,7 +7,7 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { removeWithRetry } from "./helpers/remove-with-retry.js"; From 7065009feb2c3c1cb3b6ced65f533e56c3cbb009 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:02:03 +0800 Subject: [PATCH 299/376] test: cover runtime benchmark script --- test/benchmark-runtime-path-script.test.ts | 93 ++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 test/benchmark-runtime-path-script.test.ts diff --git a/test/benchmark-runtime-path-script.test.ts b/test/benchmark-runtime-path-script.test.ts new file mode 100644 index 00000000..241d7d02 --- /dev/null +++ b/test/benchmark-runtime-path-script.test.ts @@ -0,0 +1,93 @@ +import { spawnSync } from "node:child_process"; +import { + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const tempRoots: string[] = []; + +afterEach(() => { + while (tempRoots.length > 0) { + const root = tempRoots.pop(); + if (root) rmSync(root, { recursive: true, force: true }); + } +}); + +function createFixture(): { root: string; scriptCopy: string } { + const root = mkdtempSync(path.join(tmpdir(), "runtime-bench-")); + tempRoots.push(root); + + const scriptsDir = path.join(root, "scripts"); + const distRequestDir = path.join(root, "dist", "lib", "request"); + const distRequestHelpersDir = path.join(distRequestDir, "helpers"); + const distLibDir = path.join(root, "dist", "lib"); + + mkdirSync(scriptsDir, { recursive: true }); + mkdirSync(distRequestHelpersDir, { recursive: true }); + mkdirSync(distLibDir, { recursive: true }); + + const scriptCopy = path.join(scriptsDir, "benchmark-runtime-path.mjs"); + copyFileSync( + path.join(process.cwd(), "scripts", "benchmark-runtime-path.mjs"), + scriptCopy, + ); + + writeFileSync( + path.join(distRequestDir, "request-transformer.js"), + "export function filterInput(input) { return Array.isArray(input) ? input : []; }\n", + "utf8", + ); + writeFileSync( + path.join(distRequestHelpersDir, "tool-utils.js"), + "export function cleanupToolDefinitions(tools) { return Array.isArray(tools) ? tools : []; }\n", + "utf8", + ); + writeFileSync( + path.join(distLibDir, "accounts.js"), + [ + "export class AccountManager {", + " constructor(_, storage) { this.storage = storage; }", + " getCurrentOrNextForFamilyHybrid() { return this.storage.accounts[0] ?? null; }", + "}", + ].join("\n"), + "utf8", + ); + + return { root, scriptCopy }; +} + +describe("benchmark runtime path script", () => { + it("writes a benchmark payload with expected result names", () => { + const { root, scriptCopy } = createFixture(); + const outputPath = path.join(root, "runtime-benchmark.json"); + + const result = spawnSync( + process.execPath, + [scriptCopy, "--iterations=1", `--output=${outputPath}`], + { cwd: root, encoding: "utf8" }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("Runtime benchmark written:"); + + const payload = JSON.parse(readFileSync(outputPath, "utf8")) as { + iterations: number; + results: Array<{ name: string }>; + }; + expect(payload.iterations).toBe(1); + expect(payload.results.map((entry) => entry.name)).toEqual([ + "filterInput_small", + "filterInput_large", + "cleanupToolDefinitions_medium", + "cleanupToolDefinitions_large", + "accountHybridSelection_200", + ]); + }); +}); From 26cdb9e1643399d04b6cd8e37eabf7ef69454e32 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:05:51 +0800 Subject: [PATCH 300/376] Use retry-safe cleanup in wrapper smoke test --- test/codex-multi-auth-wrapper.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/codex-multi-auth-wrapper.test.ts b/test/codex-multi-auth-wrapper.test.ts index 84c9f65f..ebd726d5 100644 --- a/test/codex-multi-auth-wrapper.test.ts +++ b/test/codex-multi-auth-wrapper.test.ts @@ -4,19 +4,21 @@ import { mkdirSync, mkdtempSync, readFileSync, - rmSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { removeWithRetry } from "./helpers/remove-with-retry.js"; const tempRoots: string[] = []; -afterEach(() => { +afterEach(async () => { while (tempRoots.length > 0) { const root = tempRoots.pop(); - if (root) rmSync(root, { recursive: true, force: true }); + if (root) { + await removeWithRetry(root, { recursive: true, force: true }); + } } }); From 7d44c75525ea4fbc61a884277d110dc9ca5d1295 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:18:09 +0800 Subject: [PATCH 301/376] Use retry-safe benchmark cleanup --- test/benchmark-runtime-path-script.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/benchmark-runtime-path-script.test.ts b/test/benchmark-runtime-path-script.test.ts index 241d7d02..2fbdee61 100644 --- a/test/benchmark-runtime-path-script.test.ts +++ b/test/benchmark-runtime-path-script.test.ts @@ -4,19 +4,21 @@ import { mkdirSync, mkdtempSync, readFileSync, - rmSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { removeWithRetry } from "./helpers/remove-with-retry.js"; const tempRoots: string[] = []; -afterEach(() => { +afterEach(async () => { while (tempRoots.length > 0) { const root = tempRoots.pop(); - if (root) rmSync(root, { recursive: true, force: true }); + if (root) { + await removeWithRetry(root, { recursive: true, force: true }); + } } }); From 75a6a6bdad522342d528d7cc58e2502d2ac950ba Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:18:09 +0800 Subject: [PATCH 302/376] Exercise target loader retry wiring --- test/experimental-sync-target-entry.test.ts | 34 +++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test/experimental-sync-target-entry.test.ts b/test/experimental-sync-target-entry.test.ts index 942740bb..e0cbff9c 100644 --- a/test/experimental-sync-target-entry.test.ts +++ b/test/experimental-sync-target-entry.test.ts @@ -24,4 +24,38 @@ describe("experimental sync target entry", () => { destination: null, }); }); + + it("wires windows-safe retry options through readJson", async () => { + const sleep = vi.fn(async () => undefined); + const readFileWithRetry = vi.fn(async () => '{"hello":"world"}'); + const normalizeAccountStorage = vi.fn(() => null); + let capturedReadJson: ((path: string) => Promise) | undefined; + + const loadExperimentalSyncTargetState = vi.fn(async (args) => { + capturedReadJson = args.readJson; + const parsed = await args.readJson("C:\\state.json"); + args.normalizeAccountStorage(parsed); + return { + kind: "target" as const, + detection: { kind: "target" as const }, + destination: null, + }; + }); + + await loadExperimentalSyncTargetEntry({ + loadExperimentalSyncTargetState, + detectTarget: () => ({ kind: "target" }) as never, + readFileWithRetry, + normalizeAccountStorage, + sleep, + }); + + expect(capturedReadJson).toBeDefined(); + expect(readFileWithRetry).toHaveBeenCalledWith("C:\\state.json", { + retryableCodes: new Set(["EBUSY", "EPERM", "EAGAIN", "ENOTEMPTY", "EACCES"]), + maxAttempts: 4, + sleep, + }); + expect(normalizeAccountStorage).toHaveBeenCalledWith({ hello: "world" }); + }); }); From c328fc7b5e102f3dd5b12a28b242a54ecf54d9ca Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:18:09 +0800 Subject: [PATCH 303/376] Exercise sync target retry wiring --- test/experimental-sync-target-entry.test.ts | 34 +++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test/experimental-sync-target-entry.test.ts b/test/experimental-sync-target-entry.test.ts index 942740bb..e0cbff9c 100644 --- a/test/experimental-sync-target-entry.test.ts +++ b/test/experimental-sync-target-entry.test.ts @@ -24,4 +24,38 @@ describe("experimental sync target entry", () => { destination: null, }); }); + + it("wires windows-safe retry options through readJson", async () => { + const sleep = vi.fn(async () => undefined); + const readFileWithRetry = vi.fn(async () => '{"hello":"world"}'); + const normalizeAccountStorage = vi.fn(() => null); + let capturedReadJson: ((path: string) => Promise) | undefined; + + const loadExperimentalSyncTargetState = vi.fn(async (args) => { + capturedReadJson = args.readJson; + const parsed = await args.readJson("C:\\state.json"); + args.normalizeAccountStorage(parsed); + return { + kind: "target" as const, + detection: { kind: "target" as const }, + destination: null, + }; + }); + + await loadExperimentalSyncTargetEntry({ + loadExperimentalSyncTargetState, + detectTarget: () => ({ kind: "target" }) as never, + readFileWithRetry, + normalizeAccountStorage, + sleep, + }); + + expect(capturedReadJson).toBeDefined(); + expect(readFileWithRetry).toHaveBeenCalledWith("C:\\state.json", { + retryableCodes: new Set(["EBUSY", "EPERM", "EAGAIN", "ENOTEMPTY", "EACCES"]), + maxAttempts: 4, + sleep, + }); + expect(normalizeAccountStorage).toHaveBeenCalledWith({ hello: "world" }); + }); }); From 3e44d11d0eb6b8f627958ab4f525984f7820689a Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:28:21 +0800 Subject: [PATCH 304/376] Preserve experimental settings entry types --- .../experimental-settings-entry.ts | 91 ++---------- .../experimental-settings-prompt.ts | 136 ++++++++++-------- lib/codex-manager/settings-hub.ts | 31 ++-- test/experimental-settings-entry.test.ts | 34 ++++- 4 files changed, 134 insertions(+), 158 deletions(-) diff --git a/lib/codex-manager/experimental-settings-entry.ts b/lib/codex-manager/experimental-settings-entry.ts index 76f4e468..306e0abb 100644 --- a/lib/codex-manager/experimental-settings-entry.ts +++ b/lib/codex-manager/experimental-settings-entry.ts @@ -1,89 +1,14 @@ import type { PluginConfig } from "../types.js"; +import type { ExperimentalSettingsPromptDeps } from "./experimental-settings-prompt.js"; -export async function promptExperimentalSettingsEntry(params: { - initialConfig: PluginConfig; - promptExperimentalSettingsMenu: (args: { +export async function promptExperimentalSettingsEntry( + params: { initialConfig: PluginConfig; - isInteractive: () => boolean; - ui: ReturnType; - cloneBackendPluginConfig: (config: PluginConfig) => PluginConfig; - select: ( - items: Array>, - options: Record, - ) => Promise; - getExperimentalSelectOptions: ( - ui: ReturnType, - help: string, - hotkeyMapper: (raw: string) => unknown, - ) => Record; - mapExperimentalMenuHotkey: (raw: string) => unknown; - mapExperimentalStatusHotkey: (raw: string) => unknown; - formatDashboardSettingState: (enabled: boolean) => string; - copy: Record; - input: NodeJS.ReadStream; - output: NodeJS.WriteStream; - runNamedBackupExport: (args: { - name: string; - }) => Promise<{ kind: string; path?: string; error?: unknown }>; - loadAccounts: () => Promise; - loadExperimentalSyncTarget: () => Promise; - planOcChatgptSync: (args: Record) => Promise; - applyOcChatgptSync: (args: Record) => Promise; - getTargetKind: (targetState: unknown) => string; - getTargetDestination: (targetState: unknown) => unknown; - getTargetDetection: (targetState: unknown) => unknown; - getTargetErrorMessage: (targetState: unknown) => string | null; - getPlanKind: (plan: unknown) => string; - getPlanBlockedReason: (plan: unknown) => string; - getPlanPreview: (plan: unknown) => { - toAdd: unknown[]; - toUpdate: unknown[]; - toSkip: unknown[]; - unchangedDestinationOnly: unknown[]; - activeSelectionBehavior: string; - }; - getAppliedLabel: (applied: unknown) => { label: string; color: string }; - }) => Promise; - isInteractive: () => boolean; - ui: ReturnType; - cloneBackendPluginConfig: (config: PluginConfig) => PluginConfig; - select: ( - items: Array>, - options: Record, - ) => Promise; - getExperimentalSelectOptions: ( - ui: ReturnType, - help: string, - hotkeyMapper: (raw: string) => unknown, - ) => Record; - mapExperimentalMenuHotkey: (raw: string) => unknown; - mapExperimentalStatusHotkey: (raw: string) => unknown; - formatDashboardSettingState: (enabled: boolean) => string; - copy: Record; - input: NodeJS.ReadStream; - output: NodeJS.WriteStream; - runNamedBackupExport: (args: { - name: string; - }) => Promise<{ kind: string; path?: string; error?: unknown }>; - loadAccounts: () => Promise; - loadExperimentalSyncTarget: () => Promise; - planOcChatgptSync: (args: Record) => Promise; - applyOcChatgptSync: (args: Record) => Promise; - getTargetKind: (targetState: unknown) => string; - getTargetDestination: (targetState: unknown) => unknown; - getTargetDetection: (targetState: unknown) => unknown; - getTargetErrorMessage: (targetState: unknown) => string | null; - getPlanKind: (plan: unknown) => string; - getPlanBlockedReason: (plan: unknown) => string; - getPlanPreview: (plan: unknown) => { - toAdd: unknown[]; - toUpdate: unknown[]; - toSkip: unknown[]; - unchangedDestinationOnly: unknown[]; - activeSelectionBehavior: string; - }; - getAppliedLabel: (applied: unknown) => { label: string; color: string }; -}): Promise { + promptExperimentalSettingsMenu: ( + args: ExperimentalSettingsPromptDeps, + ) => Promise; + } & ExperimentalSettingsPromptDeps, +): Promise { return params.promptExperimentalSettingsMenu({ initialConfig: params.initialConfig, isInteractive: params.isInteractive, diff --git a/lib/codex-manager/experimental-settings-prompt.ts b/lib/codex-manager/experimental-settings-prompt.ts index 345513f7..3f7c9110 100644 --- a/lib/codex-manager/experimental-settings-prompt.ts +++ b/lib/codex-manager/experimental-settings-prompt.ts @@ -1,75 +1,95 @@ import { createInterface } from "node:readline/promises"; +import type { + ApplyOcChatgptSyncOptions, + OcChatgptSyncApplyResult, + OcChatgptSyncPlanResult, + PlanOcChatgptSyncOptions, +} from "../oc-chatgpt-orchestrator.js"; +import type { AccountStorageV3 } from "../storage.js"; import type { PluginConfig } from "../types.js"; +import type { MenuItem, select } from "../ui/select.js"; import type { UiRuntimeOptions } from "../ui/runtime.js"; +import type { + ExperimentalSettingsAction, + getExperimentalSelectOptions, + mapExperimentalMenuHotkey, + mapExperimentalStatusHotkey, +} from "./experimental-settings-schema.js"; -export async function promptExperimentalSettingsMenu< - TAction, +export type ExperimentalSettingsCopy = { + experimentalSync: string; + experimentalBackup: string; + experimentalRefreshGuard: string; + experimentalRefreshInterval: string; + experimentalDecreaseInterval: string; + experimentalIncreaseInterval: string; + saveAndBack: string; + backNoSave: string; + experimentalHelpMenu: string; + experimentalBackupPrompt: string; + back: string; + experimentalHelpStatus: string; + experimentalApplySync: string; + experimentalHelpPreview: string; +}; + +export type ExperimentalSettingsPromptDeps< TTargetState, - TPlan, - TApplied, ->(params: { +> = { initialConfig: PluginConfig; isInteractive: () => boolean; ui: UiRuntimeOptions; cloneBackendPluginConfig: (config: PluginConfig) => PluginConfig; - select: ( - items: Array>, - options: Record, - ) => Promise; - getExperimentalSelectOptions: ( - ui: UiRuntimeOptions, - help: string, - hotkeyMapper: (raw: string) => TAction | undefined, - ) => Record; - mapExperimentalMenuHotkey: (raw: string) => TAction | undefined; - mapExperimentalStatusHotkey: (raw: string) => TAction | undefined; + select: typeof select; + getExperimentalSelectOptions: typeof getExperimentalSelectOptions; + mapExperimentalMenuHotkey: typeof mapExperimentalMenuHotkey; + mapExperimentalStatusHotkey: typeof mapExperimentalStatusHotkey; formatDashboardSettingState: (enabled: boolean) => string; - copy: { - experimentalSync: string; - experimentalBackup: string; - experimentalRefreshGuard: string; - experimentalRefreshInterval: string; - experimentalDecreaseInterval: string; - experimentalIncreaseInterval: string; - saveAndBack: string; - backNoSave: string; - experimentalHelpMenu: string; - experimentalBackupPrompt: string; - back: string; - experimentalHelpStatus: string; - experimentalApplySync: string; - experimentalHelpPreview: string; - }; + copy: ExperimentalSettingsCopy; input: NodeJS.ReadStream; output: NodeJS.WriteStream; runNamedBackupExport: (args: { name: string; }) => Promise<{ kind: string; path?: string; error?: unknown }>; - loadAccounts: () => Promise; + loadAccounts: () => Promise; loadExperimentalSyncTarget: () => Promise; - planOcChatgptSync: (args: Record) => Promise; - applyOcChatgptSync: (args: Record) => Promise; + planOcChatgptSync: ( + args: PlanOcChatgptSyncOptions, + ) => Promise; + applyOcChatgptSync: ( + args: ApplyOcChatgptSyncOptions, + ) => Promise; getTargetKind: (targetState: TTargetState) => string; - getTargetDestination: (targetState: TTargetState) => unknown; - getTargetDetection: (targetState: TTargetState) => unknown; + getTargetDestination: (targetState: TTargetState) => AccountStorageV3 | null; + getTargetDetection: ( + targetState: TTargetState, + ) => ReturnType< + typeof import("../oc-chatgpt-target-detection.js").detectOcChatgptMultiAuthTarget + >; getTargetErrorMessage: (targetState: TTargetState) => string | null; - getPlanKind: (plan: TPlan) => string; - getPlanBlockedReason: (plan: TPlan) => string; - getPlanPreview: (plan: TPlan) => { + getPlanKind: (plan: OcChatgptSyncPlanResult) => string; + getPlanBlockedReason: (plan: OcChatgptSyncPlanResult) => string; + getPlanPreview: (plan: OcChatgptSyncPlanResult) => { toAdd: unknown[]; toUpdate: unknown[]; toSkip: unknown[]; unchangedDestinationOnly: unknown[]; activeSelectionBehavior: string; }; - getAppliedLabel: (applied: TApplied) => { label: string; color: string }; -}): Promise { + getAppliedLabel: ( + applied: OcChatgptSyncApplyResult, + ) => { label: string; color: MenuItem["color"] }; +}; + +export async function promptExperimentalSettingsMenu( + params: ExperimentalSettingsPromptDeps, +): Promise { if (!params.isInteractive()) return null; let draft = params.cloneBackendPluginConfig(params.initialConfig); const copy = params.copy; while (true) { - const action = await params.select( + const action = await params.select( [ { label: copy.experimentalSync, @@ -164,7 +184,7 @@ export async function promptExperimentalSettingsMenu< : backupResult.error instanceof Error ? backupResult.error.message : String(backupResult.error); - await params.select( + await params.select( [ { label: backupLabel, @@ -184,7 +204,7 @@ export async function promptExperimentalSettingsMenu< } catch (error) { const message = error instanceof Error ? error.message : String(error); - await params.select( + await params.select( [ { label: message, @@ -212,7 +232,7 @@ export async function promptExperimentalSettingsMenu< const targetState = await params.loadExperimentalSyncTarget(); const targetError = params.getTargetErrorMessage(targetState); if (targetError) { - await params.select( + await params.select( [ { label: targetError, @@ -246,7 +266,7 @@ export async function promptExperimentalSettingsMenu< : undefined, }); if (params.getPlanKind(plan) !== "ready") { - await params.select( + await params.select( [ { label: params.getPlanBlockedReason(plan), @@ -267,7 +287,7 @@ export async function promptExperimentalSettingsMenu< } const preview = params.getPlanPreview(plan); - const review = await params.select( + const review = await params.select( [ { label: `Preview: add ${preview.toAdd.length} | update ${preview.toUpdate.length} | skip ${preview.toSkip.length}`, @@ -299,15 +319,15 @@ export async function promptExperimentalSettingsMenu< ], params.getExperimentalSelectOptions( params.ui, - copy.experimentalHelpPreview, - (raw) => { - const lower = raw.toLowerCase(); - if (lower === "q") return { type: "back" } as TAction; - if (lower === "a") return { type: "apply" } as TAction; - return undefined; - }, - ), - ); + copy.experimentalHelpPreview, + (raw) => { + const lower = raw.toLowerCase(); + if (lower === "q") return { type: "back" }; + if (lower === "a") return { type: "apply" }; + return undefined; + }, + ), + ); if (!review || (review as { type?: string }).type === "back") continue; const applied = await params.applyOcChatgptSync({ @@ -322,7 +342,7 @@ export async function promptExperimentalSettingsMenu< : undefined, }); const appliedLabel = params.getAppliedLabel(applied); - await params.select( + await params.select( [ { label: appliedLabel.label, diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 11d7628e..c4e22328 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -666,14 +666,14 @@ async function promptExperimentalSettings( ): Promise { return promptExperimentalSettingsEntry({ initialConfig, - promptExperimentalSettingsMenu: promptExperimentalSettingsMenu as never, + promptExperimentalSettingsMenu, isInteractive: () => input.isTTY && output.isTTY, ui: getUiRuntimeOptions(), cloneBackendPluginConfig, - select: select as never, - getExperimentalSelectOptions: getExperimentalSelectOptions as never, - mapExperimentalMenuHotkey: mapExperimentalMenuHotkey as never, - mapExperimentalStatusHotkey: mapExperimentalStatusHotkey as never, + select, + getExperimentalSelectOptions, + mapExperimentalMenuHotkey, + mapExperimentalStatusHotkey, formatDashboardSettingState, copy: UI_COPY.settings, input, @@ -681,13 +681,22 @@ async function promptExperimentalSettings( runNamedBackupExport, loadAccounts, loadExperimentalSyncTarget, - planOcChatgptSync: planOcChatgptSync as never, - applyOcChatgptSync: applyOcChatgptSync as never, + planOcChatgptSync, + applyOcChatgptSync, getTargetKind: (targetState) => (targetState as { kind: string }).kind, - getTargetDestination: (targetState) => - (targetState as { kind: string; destination?: unknown }).destination, - getTargetDetection: (targetState) => - (targetState as { detection?: unknown }).detection, + getTargetDestination: ( + targetState, + ): import("../storage.js").AccountStorageV3 | null => + (targetState as { + kind: string; + destination?: import("../storage.js").AccountStorageV3 | null; + }).destination ?? null, + getTargetDetection: ( + targetState, + ): ReturnType => + (targetState as { + detection: ReturnType; + }).detection, getTargetErrorMessage: (targetState) => (targetState as { kind: string; message?: string }).kind === "error" ? ((targetState as { message?: string }).message ?? "Unknown error") diff --git a/test/experimental-settings-entry.test.ts b/test/experimental-settings-entry.test.ts index 9936553c..e81e4b9c 100644 --- a/test/experimental-settings-entry.test.ts +++ b/test/experimental-settings-entry.test.ts @@ -6,10 +6,7 @@ describe("experimental settings entry", () => { const promptExperimentalSettingsMenu = vi.fn(async () => ({ fetchTimeoutMs: 1000, })); - - const result = await promptExperimentalSettingsEntry({ - initialConfig: { fetchTimeoutMs: 2000 }, - promptExperimentalSettingsMenu, + const menuDeps = { isInteractive: () => true, ui: { theme: {} } as never, cloneBackendPluginConfig: vi.fn((config) => config), @@ -18,7 +15,22 @@ describe("experimental settings entry", () => { mapExperimentalMenuHotkey: vi.fn(), mapExperimentalStatusHotkey: vi.fn(), formatDashboardSettingState: vi.fn((enabled) => (enabled ? "on" : "off")), - copy: {}, + copy: { + experimentalSync: "Sync", + experimentalBackup: "Backup", + experimentalRefreshGuard: "Refresh guard", + experimentalRefreshInterval: "Refresh interval", + experimentalDecreaseInterval: "Decrease interval", + experimentalIncreaseInterval: "Increase interval", + saveAndBack: "Save", + backNoSave: "Back", + experimentalHelpMenu: "Help menu", + experimentalBackupPrompt: "Backup prompt", + back: "Back", + experimentalHelpStatus: "Help status", + experimentalApplySync: "Apply sync", + experimentalHelpPreview: "Help preview", + }, input: process.stdin, output: process.stdout, runNamedBackupExport: vi.fn(), @@ -34,9 +46,19 @@ describe("experimental settings entry", () => { getPlanBlockedReason: vi.fn(), getPlanPreview: vi.fn(), getAppliedLabel: vi.fn(), + }; + const initialConfig = { fetchTimeoutMs: 2000 }; + + const result = await promptExperimentalSettingsEntry({ + initialConfig, + promptExperimentalSettingsMenu, + ...menuDeps, }); - expect(promptExperimentalSettingsMenu).toHaveBeenCalled(); + expect(promptExperimentalSettingsMenu).toHaveBeenCalledWith({ + initialConfig, + ...menuDeps, + }); expect(result).toEqual({ fetchTimeoutMs: 1000 }); }); }); From 3be4df4f369c073219f250f96c1d3066e3f79392 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:28:21 +0800 Subject: [PATCH 305/376] Tighten named backups entry coverage --- lib/storage/named-backups-entry.ts | 2 +- test/named-backups-entry.test.ts | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/lib/storage/named-backups-entry.ts b/lib/storage/named-backups-entry.ts index a3ce917d..3d1ede1c 100644 --- a/lib/storage/named-backups-entry.ts +++ b/lib/storage/named-backups-entry.ts @@ -1,4 +1,4 @@ -import type { NamedBackupSummary } from "../storage.js"; +import type { NamedBackupSummary } from "./named-backups.js"; export async function getNamedBackupsEntry(params: { getStoragePath: () => string; diff --git a/test/named-backups-entry.test.ts b/test/named-backups-entry.test.ts index 90685c0a..a97da014 100644 --- a/test/named-backups-entry.test.ts +++ b/test/named-backups-entry.test.ts @@ -36,4 +36,25 @@ describe("named backups entry", () => { }, ]); }); + + it("passes through windows-style storage paths unchanged", async () => { + const windowsStoragePath = "C:\\Users\\dev\\.codex\\accounts.json"; + const collectNamedBackups = vi.fn(async () => []); + const loadAccountsFromPath = vi.fn(async () => ({ + normalized: { accounts: [] }, + })); + const logDebug = vi.fn(); + + await getNamedBackupsEntry({ + getStoragePath: () => windowsStoragePath, + collectNamedBackups, + loadAccountsFromPath, + logDebug, + }); + + expect(collectNamedBackups).toHaveBeenCalledWith(windowsStoragePath, { + loadAccountsFromPath, + logDebug, + }); + }); }); From 6de014444da6defb2037183badc9ccf1a4b6250c Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:31:28 +0800 Subject: [PATCH 306/376] Fail fast on benchmark test hangs --- test/benchmark-runtime-path-script.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/benchmark-runtime-path-script.test.ts b/test/benchmark-runtime-path-script.test.ts index 2fbdee61..19d65891 100644 --- a/test/benchmark-runtime-path-script.test.ts +++ b/test/benchmark-runtime-path-script.test.ts @@ -73,7 +73,7 @@ describe("benchmark runtime path script", () => { const result = spawnSync( process.execPath, [scriptCopy, "--iterations=1", `--output=${outputPath}`], - { cwd: root, encoding: "utf8" }, + { cwd: root, encoding: "utf8", timeout: 15_000 }, ); expect(result.status).toBe(0); From 9e13a2421a13316366cd16578ce5b3ac4c96a0de Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 15:32:06 +0800 Subject: [PATCH 307/376] align gpt-5 model routing with current OpenAI defaults --- lib/capability-policy.ts | 4 +- lib/codex-manager.ts | 55 ++- lib/prompts/codex.ts | 46 +- lib/request/helpers/model-map.ts | 495 +++++++++++++++------ lib/request/request-transformer.ts | 264 ++--------- test/codex-manager-cli.test.ts | 66 +++ test/codex-prompts.test.ts | 31 +- test/codex.test.ts | 165 +------ test/config.test.ts | 20 +- test/model-map.test.ts | 282 +++++------- test/property/transformer.property.test.ts | 8 +- test/request-transformer.test.ts | 185 ++------ 12 files changed, 777 insertions(+), 844 deletions(-) diff --git a/lib/capability-policy.ts b/lib/capability-policy.ts index eb9a7f92..cbd51f09 100644 --- a/lib/capability-policy.ts +++ b/lib/capability-policy.ts @@ -1,4 +1,4 @@ -import { getNormalizedModel } from "./request/helpers/model-map.js"; +import { resolveNormalizedModel } from "./request/helpers/model-map.js"; export interface CapabilityPolicySnapshot { successes: number; @@ -33,7 +33,7 @@ function normalizeModel(model: string | undefined): string | null { const withoutProvider = trimmedInput.includes("/") ? (trimmedInput.split("/").pop() ?? trimmedInput) : trimmedInput; - const mapped = getNormalizedModel(withoutProvider) ?? withoutProvider; + const mapped = resolveNormalizedModel(withoutProvider); const trimmed = mapped.trim().toLowerCase(); if (!trimmed) return null; return trimmed.replace(/-(none|minimal|low|medium|high|xhigh)$/i, ""); diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index b80f1204..6e36b80d 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -39,6 +39,11 @@ import { } from "./forecast.js"; import { createLogger } from "./logger.js"; import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; +import { + getModelCapabilities, + getModelProfile, + resolveNormalizedModel, +} from "./request/helpers/model-map.js"; import { fetchCodexQuotaSnapshot, formatQuotaSnapshotLine, @@ -95,6 +100,14 @@ type TokenSuccessWithAccount = TokenSuccess & { type PromptTone = "accent" | "success" | "warning" | "danger" | "muted"; const log = createLogger("codex-manager"); +interface ModelInspection { + requested: string; + normalized: string; + remapped: boolean; + promptFamily: ModelFamily; + capabilities: ReturnType; +} + function stylePromptText(text: string, tone: PromptTone): string { if (!output.isTTY) return text; const ui = getUiRuntimeOptions(); @@ -117,6 +130,30 @@ function stylePromptText(text: string, tone: PromptTone): string { return `${legacyCode}${text}${ANSI.reset}`; } +function inspectRequestedModel(requestedModel: string): ModelInspection { + const normalized = resolveNormalizedModel(requestedModel); + const profile = getModelProfile(normalized); + return { + requested: requestedModel, + normalized, + remapped: requestedModel !== normalized, + promptFamily: profile.promptFamily, + capabilities: getModelCapabilities(normalized), + }; +} + +function formatModelInspection(model: ModelInspection): string { + const route = model.remapped + ? `${model.requested} -> ${model.normalized}` + : model.normalized; + return [ + route, + `prompt family ${model.promptFamily}`, + `tool search ${model.capabilities.toolSearch ? "yes" : "no"}`, + `computer use ${model.capabilities.computerUse ? "yes" : "no"}`, + ].join(" | "); +} + function collapseWhitespace(value: string): string { return value.replace(/\s+/g, " ").trim(); } @@ -1898,6 +1935,7 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { const forceRefresh = options.forceRefresh === true; const liveProbe = options.liveProbe === true; const probeModel = options.model?.trim() || "gpt-5-codex"; + const modelInspection = inspectRequestedModel(probeModel); const display = options.display ?? DEFAULT_DASHBOARD_DISPLAY_SETTINGS; const quotaCache = liveProbe ? await loadQuotaCache() : null; const workingQuotaCache = quotaCache ? cloneQuotaCacheData(quotaCache) : null; @@ -1926,6 +1964,7 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { : `Checking ${storage.accounts.length} account(s) with quick check${liveProbe ? " + live check" : ""}...`, "accent", )); + console.log(stylePromptText(`Model probe: ${formatModelInspection(modelInspection)}`, "muted")); for (let i = 0; i < storage.accounts.length; i += 1) { const account = storage.accounts[i]; if (!account) continue; @@ -1954,7 +1993,7 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { const snapshot = await fetchCodexQuotaSnapshot({ accountId: probeAccountId, accessToken: currentAccessToken, - model: probeModel, + model: modelInspection.normalized, }); if (workingQuotaCache) { quotaCacheChanged = @@ -2045,7 +2084,7 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { const snapshot = await fetchCodexQuotaSnapshot({ accountId: probeAccountId, accessToken: result.access, - model: probeModel, + model: modelInspection.normalized, }); if (workingQuotaCache) { quotaCacheChanged = @@ -2767,6 +2806,8 @@ async function runReport(args: string[]): Promise { return 1; } const options = parsedArgs.options; + const requestedModel = options.model?.trim() || "gpt-5-codex"; + const modelInspection = inspectRequestedModel(requestedModel); setStoragePath(null); const storagePath = getStoragePath(); @@ -2802,7 +2843,7 @@ async function runReport(args: string[]): Promise { const liveQuota = await fetchCodexQuotaSnapshot({ accountId, accessToken: refreshResult.access, - model: options.model, + model: modelInspection.normalized, }); liveQuotaByIndex.set(i, liveQuota); } catch (error) { @@ -2848,6 +2889,13 @@ async function runReport(args: string[]): Promise { generatedAt: new Date(now).toISOString(), storagePath, model: options.model, + modelSelection: { + requested: modelInspection.requested, + normalized: modelInspection.normalized, + remapped: modelInspection.remapped, + promptFamily: modelInspection.promptFamily, + capabilities: modelInspection.capabilities, + }, liveProbe: options.live, accounts: { total: accountCount, @@ -2878,6 +2926,7 @@ async function runReport(args: string[]): Promise { console.log(`Report generated at ${report.generatedAt}`); console.log(`Storage: ${report.storagePath}`); + console.log(`Model: ${formatModelInspection(modelInspection)}`); console.log( `Accounts: ${report.accounts.total} total (${report.accounts.enabled} enabled, ${report.accounts.disabled} disabled, ${report.accounts.coolingDown} cooling, ${report.accounts.rateLimited} rate-limited)`, ); diff --git a/lib/prompts/codex.ts b/lib/prompts/codex.ts index 434d0ad2..b21eab13 100644 --- a/lib/prompts/codex.ts +++ b/lib/prompts/codex.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"; import type { CacheMetadata, GitHubRelease } from "../types.js"; import { logWarn, logError, logDebug } from "../logger.js"; import { getCodexCacheDir } from "../runtime-paths.js"; +import { getModelProfile, type PromptModelFamily } from "../request/helpers/model-map.js"; const GITHUB_API_RELEASES = "https://api.github.com/repos/openai/codex/releases/latest"; @@ -44,12 +45,7 @@ function setCacheEntry(key: string, value: { content: string; timestamp: number * Model family type for prompt selection * Maps to different system prompts in the Codex CLI */ -export type ModelFamily = - | "gpt-5-codex" - | "codex-max" - | "codex" - | "gpt-5.2" - | "gpt-5.1"; +export type ModelFamily = PromptModelFamily; /** * All supported model families @@ -87,38 +83,16 @@ const CACHE_FILES: Record = { }; /** - * Determine the model family based on the normalized model name - * @param normalizedModel - The normalized model name (e.g., "gpt-5-codex", "gpt-5.1-codex-max", "gpt-5.2", "gpt-5.1") + * Determine the prompt family based on the effective model name. + * + * GPT-5.4-era general-purpose models intentionally stay on the GPT-5.2 prompt + * family until upstream Codex releases a newer general prompt file. + * + * @param normalizedModel - The normalized model name (e.g., "gpt-5-codex", "gpt-5.4", "gpt-5-mini") * @returns The model family for prompt selection */ export function getModelFamily(normalizedModel: string): ModelFamily { - if (normalizedModel.includes("codex-max")) { - return "codex-max"; - } - if ( - normalizedModel.includes("gpt-5-codex") || - normalizedModel.includes("gpt 5 codex") || - normalizedModel.includes("gpt-5.3-codex-spark") || - normalizedModel.includes("gpt 5.3 codex spark") || - normalizedModel.includes("gpt-5.3-codex") || - normalizedModel.includes("gpt 5.3 codex") || - normalizedModel.includes("gpt-5.2-codex") || - normalizedModel.includes("gpt 5.2 codex") || - normalizedModel.includes("gpt-5.1-codex") || - normalizedModel.includes("gpt 5.1 codex") - ) { - return "gpt-5-codex"; - } - if ( - normalizedModel.includes("codex") || - normalizedModel.startsWith("codex-") - ) { - return "codex"; - } - if (normalizedModel.includes("gpt-5.2")) { - return "gpt-5.2"; - } - return "gpt-5.1"; + return getModelProfile(normalizedModel).promptFamily; } async function readFileOrNull(path: string): Promise { @@ -396,7 +370,7 @@ function refreshInstructionsInBackground( * Prewarm instruction caches for the provided models/families. */ export function prewarmCodexInstructions(models: string[] = []): void { - const candidates = models.length > 0 ? models : ["gpt-5-codex", "gpt-5.2", "gpt-5.1"]; + const candidates = models.length > 0 ? models : ["gpt-5-codex", "gpt-5.4", "gpt-5.2"]; for (const model of candidates) { void getCodexInstructions(model).catch((error) => { logDebug("Codex instruction prewarm failed", { diff --git a/lib/request/helpers/model-map.ts b/lib/request/helpers/model-map.ts index e9cd9d5b..20a6832d 100644 --- a/lib/request/helpers/model-map.ts +++ b/lib/request/helpers/model-map.ts @@ -1,148 +1,395 @@ /** * Model Configuration Map * - * Maps model config IDs to their normalized API model names. - * Only includes exact config IDs that the host runtime will pass to the plugin. + * Maps host/runtime model identifiers to the effective model name we send to the + * OpenAI Responses API. The catalog also carries prompt-family, reasoning, and + * tool-surface metadata so routing logic stays consistent across the request + * transformer, prompt selection, and CLI diagnostics. */ +export type ModelReasoningEffort = + | "none" + | "minimal" + | "low" + | "medium" + | "high" + | "xhigh"; + +export type PromptModelFamily = + | "gpt-5-codex" + | "codex-max" + | "codex" + | "gpt-5.2" + | "gpt-5.1"; + +export interface ModelCapabilities { + toolSearch: boolean; + computerUse: boolean; +} + +export interface ModelProfile { + normalizedModel: string; + promptFamily: PromptModelFamily; + defaultReasoningEffort: ModelReasoningEffort; + supportedReasoningEfforts: readonly ModelReasoningEffort[]; + capabilities: ModelCapabilities; +} + +const REASONING_VARIANTS = [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", +] as const satisfies readonly ModelReasoningEffort[]; + +const TOOL_CAPABILITIES = { + full: { + toolSearch: true, + computerUse: true, + }, + computerOnly: { + toolSearch: false, + computerUse: true, + }, + basic: { + toolSearch: false, + computerUse: false, + }, +} as const satisfies Record; + +export const DEFAULT_MODEL = "gpt-5.4"; + /** - * Map of config model IDs to normalized API model names + * Effective model profiles keyed by canonical model name. * - * Key: The model ID as specified in runtime model config - * Value: The normalized model name to send to the API + * Prompt families intentionally stay on the latest prompt files currently + * shipped by upstream Codex CLI. GPT-5.4 era general-purpose models still use + * the GPT-5.2 prompt family because `gpt_5_4_prompt.md` is not present in the + * latest upstream release. */ -export const MODEL_MAP: Record = { - // ============================================================================ - // GPT-5 Codex Models (canonical stable family) - // ============================================================================ - "gpt-5-codex": "gpt-5-codex", - "gpt-5-codex-none": "gpt-5-codex", - "gpt-5-codex-minimal": "gpt-5-codex", - "gpt-5-codex-low": "gpt-5-codex", - "gpt-5-codex-medium": "gpt-5-codex", - "gpt-5-codex-high": "gpt-5-codex", - "gpt-5-codex-xhigh": "gpt-5-codex", - - // ============================================================================ - // GPT-5.3 Codex Spark Models (legacy aliases) - // ============================================================================ - "gpt-5.3-codex-spark": "gpt-5-codex", - "gpt-5.3-codex-spark-low": "gpt-5-codex", - "gpt-5.3-codex-spark-medium": "gpt-5-codex", - "gpt-5.3-codex-spark-high": "gpt-5-codex", - "gpt-5.3-codex-spark-xhigh": "gpt-5-codex", - - // ============================================================================ - // GPT-5.3 Codex Models (legacy aliases) - // ============================================================================ - "gpt-5.3-codex": "gpt-5-codex", - "gpt-5.3-codex-low": "gpt-5-codex", - "gpt-5.3-codex-medium": "gpt-5-codex", - "gpt-5.3-codex-high": "gpt-5-codex", - "gpt-5.3-codex-xhigh": "gpt-5-codex", - - // ============================================================================ - // GPT-5.1 Codex Models (legacy aliases) - // ============================================================================ - "gpt-5.1-codex": "gpt-5-codex", - "gpt-5.1-codex-low": "gpt-5-codex", - "gpt-5.1-codex-medium": "gpt-5-codex", - "gpt-5.1-codex-high": "gpt-5-codex", - - // ============================================================================ - // GPT-5.1 Codex Max Models - // ============================================================================ - "gpt-5.1-codex-max": "gpt-5.1-codex-max", - "gpt-5.1-codex-max-low": "gpt-5.1-codex-max", - "gpt-5.1-codex-max-medium": "gpt-5.1-codex-max", - "gpt-5.1-codex-max-high": "gpt-5.1-codex-max", - "gpt-5.1-codex-max-xhigh": "gpt-5.1-codex-max", - - // ============================================================================ - // GPT-5.2 Models (supports none/low/medium/high/xhigh per OpenAI API docs) - // ============================================================================ - "gpt-5.2": "gpt-5.2", - "gpt-5.2-none": "gpt-5.2", - "gpt-5.2-low": "gpt-5.2", - "gpt-5.2-medium": "gpt-5.2", - "gpt-5.2-high": "gpt-5.2", - "gpt-5.2-xhigh": "gpt-5.2", - - // ============================================================================ - // GPT-5.2 Codex Models (legacy aliases) - // ============================================================================ - "gpt-5.2-codex": "gpt-5-codex", - "gpt-5.2-codex-low": "gpt-5-codex", - "gpt-5.2-codex-medium": "gpt-5-codex", - "gpt-5.2-codex-high": "gpt-5-codex", - "gpt-5.2-codex-xhigh": "gpt-5-codex", - - // ============================================================================ - // GPT-5.1 Codex Mini Models - // ============================================================================ - "gpt-5.1-codex-mini": "gpt-5.1-codex-mini", - "gpt-5.1-codex-mini-medium": "gpt-5.1-codex-mini", - "gpt-5.1-codex-mini-high": "gpt-5.1-codex-mini", - - // ============================================================================ - // GPT-5.1 General Purpose Models (supports none/low/medium/high per OpenAI API docs) - // ============================================================================ - "gpt-5.1": "gpt-5.1", - "gpt-5.1-none": "gpt-5.1", - "gpt-5.1-low": "gpt-5.1", - "gpt-5.1-medium": "gpt-5.1", - "gpt-5.1-high": "gpt-5.1", - "gpt-5.1-chat-latest": "gpt-5.1", - - // ============================================================================ - // GPT-5 Codex alias (legacy/case variants) - // ============================================================================ - "gpt_5_codex": "gpt-5-codex", - - // ============================================================================ - // GPT-5 Codex Mini Models (LEGACY - maps to gpt-5.1-codex-mini) - // ============================================================================ - "codex-mini-latest": "gpt-5.1-codex-mini", - "gpt-5-codex-mini": "gpt-5.1-codex-mini", - "gpt-5-codex-mini-medium": "gpt-5.1-codex-mini", - "gpt-5-codex-mini-high": "gpt-5.1-codex-mini", - - // ============================================================================ - // GPT-5 General Purpose Models (LEGACY - maps to gpt-5.1 as gpt-5 is being phased out) - // ============================================================================ - "gpt-5": "gpt-5.1", - "gpt-5-mini": "gpt-5.1", - "gpt-5-nano": "gpt-5.1", -}; +export const MODEL_PROFILES: Record = { + "gpt-5-codex": { + normalizedModel: "gpt-5-codex", + promptFamily: "gpt-5-codex", + defaultReasoningEffort: "high", + supportedReasoningEfforts: ["low", "medium", "high", "xhigh"], + capabilities: TOOL_CAPABILITIES.basic, + }, + "gpt-5.1-codex-max": { + normalizedModel: "gpt-5.1-codex-max", + promptFamily: "codex-max", + defaultReasoningEffort: "high", + supportedReasoningEfforts: ["medium", "high", "xhigh"], + capabilities: TOOL_CAPABILITIES.basic, + }, + "gpt-5.1-codex-mini": { + normalizedModel: "gpt-5.1-codex-mini", + promptFamily: "gpt-5-codex", + defaultReasoningEffort: "medium", + supportedReasoningEfforts: ["medium", "high"], + capabilities: TOOL_CAPABILITIES.basic, + }, + "gpt-5.4": { + normalizedModel: "gpt-5.4", + promptFamily: "gpt-5.2", + defaultReasoningEffort: "none", + supportedReasoningEfforts: ["none", "low", "medium", "high", "xhigh"], + capabilities: TOOL_CAPABILITIES.full, + }, + "gpt-5.4-pro": { + normalizedModel: "gpt-5.4-pro", + promptFamily: "gpt-5.2", + defaultReasoningEffort: "high", + supportedReasoningEfforts: ["medium", "high", "xhigh"], + capabilities: TOOL_CAPABILITIES.computerOnly, + }, + "gpt-5.2-pro": { + normalizedModel: "gpt-5.2-pro", + promptFamily: "gpt-5.2", + defaultReasoningEffort: "high", + supportedReasoningEfforts: ["medium", "high", "xhigh"], + capabilities: TOOL_CAPABILITIES.basic, + }, + "gpt-5-pro": { + normalizedModel: "gpt-5-pro", + promptFamily: "gpt-5.2", + defaultReasoningEffort: "high", + supportedReasoningEfforts: ["high"], + capabilities: TOOL_CAPABILITIES.basic, + }, + "gpt-5.2": { + normalizedModel: "gpt-5.2", + promptFamily: "gpt-5.2", + defaultReasoningEffort: "none", + supportedReasoningEfforts: ["none", "low", "medium", "high", "xhigh"], + capabilities: TOOL_CAPABILITIES.basic, + }, + "gpt-5.1": { + normalizedModel: "gpt-5.1", + promptFamily: "gpt-5.1", + defaultReasoningEffort: "none", + supportedReasoningEfforts: ["none", "low", "medium", "high"], + capabilities: TOOL_CAPABILITIES.basic, + }, + "gpt-5": { + normalizedModel: "gpt-5", + promptFamily: "gpt-5.2", + defaultReasoningEffort: "medium", + supportedReasoningEfforts: ["minimal", "low", "medium", "high"], + capabilities: TOOL_CAPABILITIES.basic, + }, + "gpt-5-mini": { + normalizedModel: "gpt-5-mini", + promptFamily: "gpt-5.2", + defaultReasoningEffort: "medium", + supportedReasoningEfforts: ["medium"], + capabilities: TOOL_CAPABILITIES.basic, + }, + "gpt-5-nano": { + normalizedModel: "gpt-5-nano", + promptFamily: "gpt-5.2", + defaultReasoningEffort: "medium", + supportedReasoningEfforts: ["medium"], + capabilities: TOOL_CAPABILITIES.basic, + }, +} as const; + +const MODEL_MAP: Record = {}; + +function addAlias(alias: string, normalizedModel: string): void { + MODEL_MAP[alias] = normalizedModel; +} + +function addReasoningAliases(alias: string, normalizedModel: string): void { + addAlias(alias, normalizedModel); + for (const variant of REASONING_VARIANTS) { + addAlias(`${alias}-${variant}`, normalizedModel); + } +} + +function addGeneralAliases(): void { + addReasoningAliases("gpt-5.4", "gpt-5.4"); + addReasoningAliases("gpt-5.4-pro", "gpt-5.4-pro"); + addReasoningAliases("gpt-5.2-pro", "gpt-5.2-pro"); + addReasoningAliases("gpt-5-pro", "gpt-5-pro"); + addReasoningAliases("gpt-5.2", "gpt-5.2"); + addReasoningAliases("gpt-5.1", "gpt-5.1"); + addReasoningAliases("gpt-5", "gpt-5"); + addReasoningAliases("gpt-5-mini", "gpt-5-mini"); + addReasoningAliases("gpt-5-nano", "gpt-5-nano"); + + addAlias("gpt-5.1-chat-latest", "gpt-5.1"); + addAlias("gpt-5-chat-latest", "gpt-5"); + addReasoningAliases("gpt-5.4-mini", "gpt-5-mini"); + addReasoningAliases("gpt-5.4-nano", "gpt-5-nano"); +} + +function addCodexAliases(): void { + addReasoningAliases("gpt-5-codex", "gpt-5-codex"); + addReasoningAliases("gpt-5.3-codex-spark", "gpt-5-codex"); + addReasoningAliases("gpt-5.3-codex", "gpt-5-codex"); + addReasoningAliases("gpt-5.2-codex", "gpt-5-codex"); + addReasoningAliases("gpt-5.1-codex", "gpt-5-codex"); + addAlias("gpt_5_codex", "gpt-5-codex"); + + addReasoningAliases("gpt-5.1-codex-max", "gpt-5.1-codex-max"); + + addAlias("codex-mini-latest", "gpt-5.1-codex-mini"); + addReasoningAliases("gpt-5-codex-mini", "gpt-5.1-codex-mini"); + addReasoningAliases("gpt-5.1-codex-mini", "gpt-5.1-codex-mini"); +} + +addCodexAliases(); +addGeneralAliases(); + +export { MODEL_MAP }; + +function stripProviderPrefix(modelId: string): string { + return modelId.includes("/") ? (modelId.split("/").pop() ?? modelId) : modelId; +} + +function lookupMappedModel(modelId: string): string | undefined { + if (Object.hasOwn(MODEL_MAP, modelId)) { + return MODEL_MAP[modelId]; + } + + const lowerModelId = modelId.toLowerCase(); + const match = Object.keys(MODEL_MAP).find( + (key) => key.toLowerCase() === lowerModelId, + ); + + return match ? MODEL_MAP[match] : undefined; +} /** - * Get normalized model name from config ID + * Get normalized model name from a known config/runtime identifier. * - * @param modelId - Model ID from config (e.g., "gpt-5.1-codex-low") - * @returns Normalized model name (e.g., "gpt-5.1-codex") or undefined if not found + * This does exact/alias lookup only. Use `resolveNormalizedModel()` when you + * want GPT-5 family fallback behavior for unknown-but-similar names. */ export function getNormalizedModel(modelId: string): string | undefined { try { - if (Object.hasOwn(MODEL_MAP, modelId)) { - return MODEL_MAP[modelId]; - } - - const lowerModelId = modelId.toLowerCase(); - const match = Object.keys(MODEL_MAP).find( - (key) => key.toLowerCase() === lowerModelId, - ); - - return match ? MODEL_MAP[match] : undefined; + const stripped = stripProviderPrefix(modelId.trim()); + if (!stripped) return undefined; + return lookupMappedModel(stripped); } catch { return undefined; } } /** - * Check if a model ID is in the model map + * Resolve a model identifier to the effective API model. + * + * This expands exact alias lookup with GPT-5 family fallback rules so the + * plugin never silently downgrades modern GPT-5 requests to GPT-5.1-era + * routing. + */ +export function resolveNormalizedModel(model: string | undefined): string { + if (!model) return DEFAULT_MODEL; + + const modelId = stripProviderPrefix(model).trim(); + if (!modelId) return DEFAULT_MODEL; + + const mappedModel = lookupMappedModel(modelId); + if (mappedModel) { + return mappedModel; + } + + const normalized = modelId.toLowerCase(); + + if ( + normalized.includes("gpt-5.3-codex-spark") || + normalized.includes("gpt 5.3 codex spark") + ) { + return "gpt-5-codex"; + } + if ( + normalized.includes("gpt-5.3-codex") || + normalized.includes("gpt 5.3 codex") + ) { + return "gpt-5-codex"; + } + if ( + normalized.includes("gpt-5.2-codex") || + normalized.includes("gpt 5.2 codex") + ) { + return "gpt-5-codex"; + } + if ( + normalized.includes("gpt-5.1-codex-max") || + normalized.includes("gpt 5.1 codex max") + ) { + return "gpt-5.1-codex-max"; + } + if ( + normalized.includes("gpt-5.1-codex-mini") || + normalized.includes("gpt 5.1 codex mini") || + normalized.includes("codex-mini-latest") || + normalized.includes("gpt-5-codex-mini") || + normalized.includes("gpt 5 codex mini") + ) { + return "gpt-5.1-codex-mini"; + } + if ( + normalized.includes("gpt-5-codex") || + normalized.includes("gpt 5 codex") || + normalized.includes("gpt-5.1-codex") || + normalized.includes("gpt 5.1 codex") || + normalized.includes("codex") + ) { + return "gpt-5-codex"; + } + if ( + normalized.includes("gpt-5.4-pro") || + normalized.includes("gpt 5.4 pro") + ) { + return "gpt-5.4-pro"; + } + if ( + normalized.includes("gpt-5.2-pro") || + normalized.includes("gpt 5.2 pro") + ) { + return "gpt-5.2-pro"; + } + if ( + normalized.includes("gpt-5-pro") || + normalized.includes("gpt 5 pro") + ) { + return "gpt-5-pro"; + } + if ( + normalized.includes("gpt-5.4-mini") || + normalized.includes("gpt 5.4 mini") || + normalized.includes("gpt-5-mini") || + normalized.includes("gpt 5 mini") + ) { + return "gpt-5-mini"; + } + if ( + normalized.includes("gpt-5.4-nano") || + normalized.includes("gpt 5.4 nano") || + normalized.includes("gpt-5-nano") || + normalized.includes("gpt 5 nano") + ) { + return "gpt-5-nano"; + } + if ( + normalized.includes("gpt-5.4") || + normalized.includes("gpt 5.4") + ) { + return "gpt-5.4"; + } + if ( + normalized.includes("gpt-5.2") || + normalized.includes("gpt 5.2") + ) { + return "gpt-5.2"; + } + if ( + normalized.includes("gpt-5.1") || + normalized.includes("gpt 5.1") + ) { + return "gpt-5.1"; + } + if (normalized === "gpt-5" || normalized.includes("gpt-5") || normalized.includes("gpt 5")) { + return "gpt-5.4"; + } + + return DEFAULT_MODEL; +} + +/** + * Resolve the effective model profile for a requested model string. + */ +export function getModelProfile(model: string | undefined): ModelProfile { + const normalizedModel = resolveNormalizedModel(model); + const profile = MODEL_PROFILES[normalizedModel]; + if (profile) { + return profile; + } + + const fallbackProfile = MODEL_PROFILES[DEFAULT_MODEL]; + if (fallbackProfile) { + return fallbackProfile; + } + + throw new Error(`Default model profile is missing for ${DEFAULT_MODEL}`); +} + +/** + * Expose current tool-surface metadata for diagnostics and capability checks. + */ +export function getModelCapabilities(model: string | undefined): ModelCapabilities { + return getModelProfile(model).capabilities; +} + +/** + * Check if a model ID is in the explicit model map. * - * @param modelId - Model ID to check - * @returns True if model is in the map + * This only returns `true` for exact known aliases. Use + * `resolveNormalizedModel()` if you want the fallback behavior. */ export function isKnownModel(modelId: string): boolean { return getNormalizedModel(modelId) !== undefined; diff --git a/lib/request/request-transformer.ts b/lib/request/request-transformer.ts index 33a4715e..6c002476 100644 --- a/lib/request/request-transformer.ts +++ b/lib/request/request-transformer.ts @@ -2,7 +2,11 @@ import { logDebug, logWarn } from "../logger.js"; import { TOOL_REMAP_MESSAGE } from "../prompts/codex.js"; import { CODEX_HOST_BRIDGE } from "../prompts/codex-host-bridge.js"; import { getHostCodexPrompt } from "../prompts/host-codex-prompt.js"; -import { getNormalizedModel } from "./helpers/model-map.js"; +import { + getModelProfile, + resolveNormalizedModel, + type ModelReasoningEffort, +} from "./helpers/model-map.js"; import { filterHostSystemPromptsWithCachedPrompt, normalizeOrphanedToolOutputs, @@ -41,117 +45,14 @@ export { /** * Normalize model name to Codex-supported variants * - * Uses explicit model map for known models, with fallback pattern matching - * for unknown/custom model names. + * Uses the shared model catalog so request routing, prompt selection, and CLI + * diagnostics all agree on the same effective model. * * @param model - Original model name (e.g., "gpt-5-codex-low", "openai/gpt-5-codex") - * @returns Normalized model name (e.g., "gpt-5-codex", "gpt-5.1-codex-max") + * @returns Normalized model name (e.g., "gpt-5-codex", "gpt-5.4", "gpt-5.1-codex-max") */ export function normalizeModel(model: string | undefined): string { - if (!model) return "gpt-5.1"; - - // Strip provider prefix if present (e.g., "openai/gpt-5-codex" → "gpt-5-codex") - const modelId = model.includes("/") ? model.split("/").pop() ?? model : model; - - // Try explicit model map first (handles all known model variants) - const mappedModel = getNormalizedModel(modelId); - if (mappedModel) { - return mappedModel; - } - - // Fallback: Pattern-based matching for unknown/custom model names - // This preserves backwards compatibility with old verbose names - // like "GPT 5 Codex Low (ChatGPT Subscription)" - const normalized = modelId.toLowerCase(); - - // Priority order for pattern matching (most specific first): - // 1. GPT-5.3 Codex Spark (legacy alias -> canonical gpt-5-codex) - if ( - normalized.includes("gpt-5.3-codex-spark") || - normalized.includes("gpt 5.3 codex spark") - ) { - return "gpt-5-codex"; - } - - // 2. GPT-5.3 Codex (legacy alias -> canonical gpt-5-codex) - if ( - normalized.includes("gpt-5.3-codex") || - normalized.includes("gpt 5.3 codex") - ) { - return "gpt-5-codex"; - } - - // 3. GPT-5.2 Codex (legacy alias -> canonical gpt-5-codex) - if ( - normalized.includes("gpt-5.2-codex") || - normalized.includes("gpt 5.2 codex") - ) { - return "gpt-5-codex"; - } - - // 4. GPT-5.2 (general purpose) - if (normalized.includes("gpt-5.2") || normalized.includes("gpt 5.2")) { - return "gpt-5.2"; - } - - // 5. GPT-5.1 Codex Max - if ( - normalized.includes("gpt-5.1-codex-max") || - normalized.includes("gpt 5.1 codex max") - ) { - return "gpt-5.1-codex-max"; - } - - // 6. GPT-5.1 Codex Mini - if ( - normalized.includes("gpt-5.1-codex-mini") || - normalized.includes("gpt 5.1 codex mini") - ) { - return "gpt-5.1-codex-mini"; - } - - // 7. Legacy Codex Mini - if ( - normalized.includes("codex-mini-latest") || - normalized.includes("gpt-5-codex-mini") || - normalized.includes("gpt 5 codex mini") - ) { - return "gpt-5.1-codex-mini"; - } - - // 8. GPT-5 Codex canonical + GPT-5.1 Codex legacy alias - if ( - normalized.includes("gpt-5-codex") || - normalized.includes("gpt 5 codex") - ) { - return "gpt-5-codex"; - } - - // 9. GPT-5.1 Codex (legacy alias) - if ( - normalized.includes("gpt-5.1-codex") || - normalized.includes("gpt 5.1 codex") - ) { - return "gpt-5-codex"; - } - - // 10. GPT-5.1 (general-purpose) - if (normalized.includes("gpt-5.1") || normalized.includes("gpt 5.1")) { - return "gpt-5.1"; - } - - // 11. GPT-5 Codex family (any other variant with "codex") - if (normalized.includes("codex")) { - return "gpt-5-codex"; - } - - // 12. GPT-5 family (any variant) - default to 5.1 - if (normalized.includes("gpt-5") || normalized.includes("gpt 5")) { - return "gpt-5.1"; - } - - // Default fallback - return "gpt-5.1"; + return resolveNormalizedModel(model); } /** @@ -399,114 +300,14 @@ export function getReasoningConfig( modelName: string | undefined, userConfig: ConfigOptions = {}, ): ReasoningConfig { - const normalizedName = modelName?.toLowerCase() ?? ""; - - // Canonical GPT-5 Codex (stable) defaults to high and does not support "none". - const isGpt5Codex = - normalizedName.includes("gpt-5-codex") || - normalizedName.includes("gpt 5 codex"); - - // Legacy GPT-5.3 Codex alias behavior (supports xhigh, but not "none") - const isGpt53Codex = - normalizedName.includes("gpt-5.3-codex") || - normalizedName.includes("gpt 5.3 codex"); - - // Legacy GPT-5.2 Codex alias behavior (supports xhigh, but not "none") - const isGpt52Codex = - normalizedName.includes("gpt-5.2-codex") || - normalizedName.includes("gpt 5.2 codex"); - - // GPT-5.2 general purpose (not codex variant) - const isGpt52General = - (normalizedName.includes("gpt-5.2") || normalizedName.includes("gpt 5.2")) && - !isGpt52Codex; - const isCodexMax = - normalizedName.includes("codex-max") || - normalizedName.includes("codex max"); - const isCodexMini = - normalizedName.includes("codex-mini") || - normalizedName.includes("codex mini") || - normalizedName.includes("codex_mini") || - normalizedName.includes("codex-mini-latest"); - const isCodex = normalizedName.includes("codex") && !isCodexMini; - const isLightweight = - !isCodexMini && - (normalizedName.includes("nano") || - normalizedName.includes("mini")); - - // GPT-5.1 general purpose (not codex variants) - supports "none" per OpenAI API docs - const isGpt51General = - ( - normalizedName.includes("gpt-5.1") || - normalizedName.includes("gpt 5.1") || - normalizedName === "gpt-5" || - normalizedName.startsWith("gpt-5-") - ) && - !isCodex && - !isGpt52General && - !isCodexMax && - !isCodexMini; - - // GPT-5.2 general, legacy GPT-5.2/5.3 Codex aliases, and Codex Max support xhigh reasoning - const supportsXhigh = - isGpt52General || isGpt53Codex || isGpt52Codex || isCodexMax; - - // GPT 5.1 general and GPT 5.2 general support "none" reasoning per: - // - OpenAI API docs: "gpt-5.1 defaults to none, supports: none, low, medium, high" - // - Codex CLI: ReasoningEffort enum includes None variant (codex-rs/protocol/src/openai_models.rs) - // - Codex CLI: docs/config.md lists "none" as valid for model_reasoning_effort - // - gpt-5.2 (being newer) also supports: none, low, medium, high, xhigh - // - Codex models (including GPT-5 Codex and legacy GPT-5.3/5.2 Codex aliases) do NOT support "none" - const supportsNone = isGpt52General || isGpt51General; - - // Default based on model type (Codex CLI defaults + plugin opinionated tuning) - // Note: OpenAI docs say gpt-5.1 defaults to "none", but we default to "medium" - // for better coding assistance unless user explicitly requests "none". - // - Canonical GPT-5 Codex defaults to high in stable Codex. - // - Legacy GPT-5.3/5.2 Codex aliases default to xhigh for backward compatibility. - const defaultEffort: ReasoningConfig["effort"] = isCodexMini - ? "medium" - : isGpt5Codex - ? "high" - : isGpt53Codex || isGpt52Codex - ? "xhigh" - : supportsXhigh - ? "high" - : isLightweight - ? "minimal" - : "medium"; - - // Get user-requested effort - let effort = userConfig.reasoningEffort || defaultEffort; - - if (isCodexMini) { - if (effort === "minimal" || effort === "low" || effort === "none") { - effort = "medium"; - } - if (effort === "xhigh") { - effort = "high"; - } - if (effort !== "high" && effort !== "medium") { - effort = "medium"; - } - } - - // For models that don't support xhigh, downgrade to high - if (!supportsXhigh && effort === "xhigh") { - effort = "high"; - } - - // For models that don't support "none", upgrade to "low" - // (Codex models don't support "none" - only GPT-5.1 and GPT-5.2 general purpose do) - if (!supportsNone && effort === "none") { - effort = "low"; - } - - // Normalize "minimal" to "low" for Codex families - // Codex CLI presets are low/medium/high (or xhigh for Codex Max / GPT-5.3/5.2 Codex) - if (isCodex && effort === "minimal") { - effort = "low"; - } + const profile = getModelProfile(modelName); + const defaultEffort = profile.defaultReasoningEffort; + const requestedEffort = userConfig.reasoningEffort ?? defaultEffort; + const effort = coerceReasoningEffort( + requestedEffort, + profile.supportedReasoningEfforts, + defaultEffort, + ); const summary = sanitizeReasoningSummary(userConfig.reasoningSummary); @@ -516,6 +317,37 @@ export function getReasoningConfig( }; } +const REASONING_FALLBACKS: Record< + ModelReasoningEffort, + readonly ModelReasoningEffort[] +> = { + none: ["none", "low", "minimal", "medium", "high", "xhigh"], + minimal: ["minimal", "low", "none", "medium", "high", "xhigh"], + low: ["low", "minimal", "none", "medium", "high", "xhigh"], + medium: ["medium", "low", "high", "minimal", "none", "xhigh"], + high: ["high", "medium", "xhigh", "low", "minimal", "none"], + xhigh: ["xhigh", "high", "medium", "low", "minimal", "none"], +} as const; + +function coerceReasoningEffort( + effort: ModelReasoningEffort, + supportedEfforts: readonly ModelReasoningEffort[], + defaultEffort: ModelReasoningEffort, +): ReasoningConfig["effort"] { + if (supportedEfforts.includes(effort)) { + return effort; + } + + const fallbackOrder = REASONING_FALLBACKS[effort] ?? [defaultEffort]; + for (const candidate of fallbackOrder) { + if (supportedEfforts.includes(candidate)) { + return candidate; + } + } + + return defaultEffort; +} + function sanitizeReasoningSummary( summary: ConfigOptions["reasoningSummary"], ): SupportedReasoningSummary { diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 930cf8fb..613d6c93 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -5702,11 +5702,77 @@ describe("codex manager cli commands", () => { const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { command: string; accounts: { total: number; enabled: number; disabled: number }; + modelSelection: { + requested: string; + normalized: string; + remapped: boolean; + promptFamily: string; + capabilities: { toolSearch: boolean; computerUse: boolean }; + }; }; expect(payload.command).toBe("report"); expect(payload.accounts.total).toBe(2); expect(payload.accounts.enabled).toBe(1); expect(payload.accounts.disabled).toBe(1); + expect(payload.modelSelection).toEqual({ + requested: "gpt-5-codex", + normalized: "gpt-5-codex", + remapped: false, + promptFamily: "gpt-5-codex", + capabilities: { + toolSearch: false, + computerUse: false, + }, + }); + }); + + it("reports normalized model routing and capabilities for remapped report probes", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "real@example.net", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli([ + "auth", + "report", + "--json", + "--model", + "gpt-5.4-mini", + ]); + + expect(exitCode).toBe(0); + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + modelSelection: { + requested: string; + normalized: string; + remapped: boolean; + promptFamily: string; + capabilities: { toolSearch: boolean; computerUse: boolean }; + }; + }; + expect(payload.modelSelection).toEqual({ + requested: "gpt-5.4-mini", + normalized: "gpt-5-mini", + remapped: true, + promptFamily: "gpt-5.2", + capabilities: { + toolSearch: false, + computerUse: false, + }, + }); }); it("drives interactive settings hub across sections and persists dashboard/backend changes", async () => { diff --git a/test/codex-prompts.test.ts b/test/codex-prompts.test.ts index 17131b8f..60a190e4 100644 --- a/test/codex-prompts.test.ts +++ b/test/codex-prompts.test.ts @@ -86,9 +86,15 @@ describe("Codex Prompts Module", () => { expect(getModelFamily("gpt-5.1-codex-mini-low")).toBe("gpt-5-codex"); }); + it("should route GPT-5.4 era general models through the latest available general prompt family", () => { + expect(getModelFamily("gpt-5.4")).toBe("gpt-5.2"); + expect(getModelFamily("gpt-5.4-pro")).toBe("gpt-5.2"); + expect(getModelFamily("gpt-5-mini")).toBe("gpt-5.2"); + }); + it("should detect models starting with codex-", () => { - expect(getModelFamily("codex-mini")).toBe("codex"); - expect(getModelFamily("codex-latest")).toBe("codex"); + expect(getModelFamily("codex-mini")).toBe("gpt-5-codex"); + expect(getModelFamily("codex-latest")).toBe("gpt-5-codex"); }); }); @@ -457,6 +463,27 @@ describe("Codex Prompts Module", () => { ); expect(rawGitHubCall?.[0]).toContain("gpt_5_codex_prompt.md"); }); + + it("should map gpt-5.4 prompts to the latest available general prompt file", async () => { + mockedReadFile.mockRejectedValue(new Error("ENOENT")); + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ tag_name: "rust-v0.116.0" }), + text: () => Promise.resolve("content"), + headers: { get: () => "etag" }, + }); + mockedMkdir.mockResolvedValue(undefined); + mockedWriteFile.mockResolvedValue(undefined); + + await getCodexInstructions("gpt-5.4"); + const fetchCalls = mockFetch.mock.calls; + const rawGitHubCall = fetchCalls.find( + (call) => + typeof call[0] === "string" && + call[0].includes("raw.githubusercontent.com"), + ); + expect(rawGitHubCall?.[0]).toContain("gpt_5_2_prompt.md"); + }); }); }); }); diff --git a/test/codex.test.ts b/test/codex.test.ts index 85bc4fbf..533cb988 100644 --- a/test/codex.test.ts +++ b/test/codex.test.ts @@ -1,155 +1,36 @@ -import { describe, it, expect } from "vitest"; -import { getModelFamily } from "../lib/prompts/codex.js"; - - describe("Codex Module", () => { - describe("getModelFamily", () => { - describe("GPT-5.3 Codex Spark family", () => { - it("should return gpt-5.3-codex-spark for gpt-5.3-codex-spark", () => { - expect(getModelFamily("gpt-5.3-codex-spark")).toBe("gpt-5-codex"); - }); - - it("should return gpt-5.3-codex-spark for gpt-5.3-codex-spark-high", () => { - expect(getModelFamily("gpt-5.3-codex-spark-high")).toBe("gpt-5-codex"); - }); - }); - - describe("GPT-5.3 Codex family", () => { - it("should return gpt-5.3-codex for gpt-5.3-codex", () => { - expect(getModelFamily("gpt-5.3-codex")).toBe("gpt-5-codex"); - }); - - it("should return gpt-5.3-codex for gpt-5.3-codex-low", () => { - expect(getModelFamily("gpt-5.3-codex-low")).toBe("gpt-5-codex"); - }); - - it("should return gpt-5.3-codex for gpt-5.3-codex-high", () => { - expect(getModelFamily("gpt-5.3-codex-high")).toBe("gpt-5-codex"); - }); - - it("should return gpt-5.3-codex for gpt-5.3-codex-xhigh", () => { - expect(getModelFamily("gpt-5.3-codex-xhigh")).toBe("gpt-5-codex"); - }); - }); - - describe("GPT-5.2 Codex family", () => { - it("should return gpt-5.2-codex for gpt-5.2-codex", () => { - expect(getModelFamily("gpt-5.2-codex")).toBe("gpt-5-codex"); - }); - - it("should return gpt-5.2-codex for gpt-5.2-codex-low", () => { - expect(getModelFamily("gpt-5.2-codex-low")).toBe("gpt-5-codex"); - }); - - it("should return gpt-5.2-codex for gpt-5.2-codex-high", () => { - expect(getModelFamily("gpt-5.2-codex-high")).toBe("gpt-5-codex"); - }); - - it("should return gpt-5.2-codex for gpt-5.2-codex-xhigh", () => { - expect(getModelFamily("gpt-5.2-codex-xhigh")).toBe("gpt-5-codex"); - }); +import { describe, expect, it } from "vitest"; +import { __clearCacheForTesting, getModelFamily } from "../lib/prompts/codex.js"; + +describe("Codex Module", () => { + describe("getModelFamily", () => { + it("keeps codex variants on codex prompt families", () => { + expect(getModelFamily("gpt-5.3-codex-spark")).toBe("gpt-5-codex"); + expect(getModelFamily("gpt-5.2-codex-high")).toBe("gpt-5-codex"); + expect(getModelFamily("gpt-5.1-codex-max-high")).toBe("codex-max"); + expect(getModelFamily("gpt-5.1-codex-mini-high")).toBe("gpt-5-codex"); + expect(getModelFamily("codex-mini-latest")).toBe("gpt-5-codex"); }); - describe("Codex Max family", () => { - it("should return codex-max for gpt-5.1-codex-max", () => { - expect(getModelFamily("gpt-5.1-codex-max")).toBe("codex-max"); - }); - - it("should return codex-max for gpt-5.1-codex-max-low", () => { - expect(getModelFamily("gpt-5.1-codex-max-low")).toBe("codex-max"); - }); - - it("should return codex-max for gpt-5.1-codex-max-high", () => { - expect(getModelFamily("gpt-5.1-codex-max-high")).toBe("codex-max"); - }); - - it("should return codex-max for gpt-5.1-codex-max-xhigh", () => { - expect(getModelFamily("gpt-5.1-codex-max-xhigh")).toBe("codex-max"); - }); + it("routes GPT-5.4-era general models through the latest upstream general prompt family", () => { + expect(getModelFamily("gpt-5.4")).toBe("gpt-5.2"); + expect(getModelFamily("gpt-5.4-pro")).toBe("gpt-5.2"); + expect(getModelFamily("gpt-5")).toBe("gpt-5.2"); + expect(getModelFamily("gpt-5-mini")).toBe("gpt-5.2"); + expect(getModelFamily("gpt-5-nano")).toBe("gpt-5.2"); }); - describe("Codex family", () => { - it("should return codex for gpt-5.1-codex", () => { - expect(getModelFamily("gpt-5.1-codex")).toBe("gpt-5-codex"); - }); - - it("should return codex for gpt-5.1-codex-low", () => { - expect(getModelFamily("gpt-5.1-codex-low")).toBe("gpt-5-codex"); - }); - - it("should return codex for gpt-5.1-codex-mini", () => { - expect(getModelFamily("gpt-5.1-codex-mini")).toBe("gpt-5-codex"); - }); - - it("should return codex for gpt-5.1-codex-mini-high", () => { - expect(getModelFamily("gpt-5.1-codex-mini-high")).toBe("gpt-5-codex"); - }); - - it("should return codex for codex-mini-latest", () => { - expect(getModelFamily("codex-mini-latest")).toBe("codex"); - }); - }); - - describe("GPT-5.1 general family", () => { - it("should return gpt-5.1 for gpt-5.1", () => { - expect(getModelFamily("gpt-5.1")).toBe("gpt-5.1"); - }); - - it("should return gpt-5.1 for gpt-5.1-low", () => { - expect(getModelFamily("gpt-5.1-low")).toBe("gpt-5.1"); - }); - - it("should return gpt-5.1 for gpt-5.1-high", () => { - expect(getModelFamily("gpt-5.1-high")).toBe("gpt-5.1"); - }); - - it("should return gpt-5.1 for unknown models", () => { - expect(getModelFamily("unknown-model")).toBe("gpt-5.1"); - }); - - it("should return gpt-5.1 for empty string", () => { - expect(getModelFamily("")).toBe("gpt-5.1"); - }); + it("keeps GPT-5.1 on its own prompt family", () => { + expect(getModelFamily("gpt-5.1")).toBe("gpt-5.1"); + expect(getModelFamily("gpt-5.1-high")).toBe("gpt-5.1"); }); - describe("GPT-5.2 general family", () => { - it("should return gpt-5.2 for gpt-5.2", () => { - expect(getModelFamily("gpt-5.2")).toBe("gpt-5.2"); - }); - - it("should return gpt-5.2 for gpt-5.2-high", () => { - expect(getModelFamily("gpt-5.2-high")).toBe("gpt-5.2"); - }); - }); - - describe("Priority order", () => { - it("should prioritize gpt-5.3-codex-spark over gpt-5.3-codex detection", () => { - expect(getModelFamily("gpt-5.3-codex-spark")).toBe("gpt-5-codex"); - }); - - it("should prioritize gpt-5.3-codex over generic codex detection", () => { - expect(getModelFamily("gpt-5.3-codex")).toBe("gpt-5-codex"); - }); - - it("should prioritize gpt-5.2-codex over gpt-5.2 general", () => { - // "gpt-5.2-codex" also contains the substring "gpt-5.2" - expect(getModelFamily("gpt-5.2-codex")).toBe("gpt-5-codex"); - }); - - it("should prioritize codex-max over codex", () => { - // Model contains both "codex-max" and "codex" - expect(getModelFamily("gpt-5.1-codex-max")).toBe("codex-max"); - }); - - it("should prioritize codex over gpt-5.1", () => { - // Model contains both "codex" and potential gpt-5.1 - expect(getModelFamily("gpt-5.1-codex")).toBe("gpt-5-codex"); - }); + it("falls back to the default model profile for unknown models", () => { + expect(getModelFamily("unknown-model")).toBe("gpt-5.2"); + expect(getModelFamily("")).toBe("gpt-5.2"); }); }); }); -import { __clearCacheForTesting } from "../lib/prompts/codex.js"; - describe("Codex Cache", () => { it("should clear prompt cache without error", () => { expect(() => __clearCacheForTesting()).not.toThrow(); diff --git a/test/config.test.ts b/test/config.test.ts index 79c982ce..cec3149d 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -90,10 +90,10 @@ describe('Configuration Parsing', () => { expect(defaultReasoning.summary).toBe('auto'); }); - it('should use minimal effort for lightweight models (nano/mini)', () => { + it('should keep lightweight general models on their fixed medium reasoning tier', () => { const nanoReasoning = getReasoningConfig('gpt-5-nano', {}); - expect(nanoReasoning.effort).toBe('minimal'); + expect(nanoReasoning.effort).toBe('medium'); expect(nanoReasoning.summary).toBe('auto'); }); @@ -105,13 +105,18 @@ describe('Configuration Parsing', () => { expect(codexMinimalReasoning.summary).toBe('auto'); }); - it('should preserve "minimal" effort for non-codex models', () => { + it('should preserve "minimal" effort for GPT-5 general models that still support it', () => { const gpt5MinimalConfig = { reasoningEffort: 'minimal' as const }; const gpt5MinimalReasoning = getReasoningConfig('gpt-5', gpt5MinimalConfig); expect(gpt5MinimalReasoning.effort).toBe('minimal'); }); + it('should default GPT-5.4 general models to none reasoning', () => { + const gpt54Reasoning = getReasoningConfig('gpt-5.4', {}); + expect(gpt54Reasoning.effort).toBe('none'); + }); + it('should handle high effort setting', () => { const highConfig = { reasoningEffort: 'high' as const }; const highReasoning = getReasoningConfig('gpt-5', highConfig); @@ -169,7 +174,7 @@ describe('Configuration Parsing', () => { describe('Model-specific behavior', () => { it('should detect lightweight models correctly', () => { const miniReasoning = getReasoningConfig('gpt-5-mini', {}); - expect(miniReasoning.effort).toBe('minimal'); + expect(miniReasoning.effort).toBe('medium'); }); it('should detect codex models correctly', () => { @@ -182,5 +187,12 @@ describe('Configuration Parsing', () => { const gpt5Reasoning = getReasoningConfig('gpt-5', {}); expect(gpt5Reasoning.effort).toBe('medium'); }); + + it('should clamp unsupported low effort on GPT-5.4-pro up to medium', () => { + const gpt54ProReasoning = getReasoningConfig('gpt-5.4-pro', { + reasoningEffort: 'low', + }); + expect(gpt54ProReasoning.effort).toBe('medium'); + }); }); }); diff --git a/test/model-map.test.ts b/test/model-map.test.ts index 21f7cb5b..6ad16967 100644 --- a/test/model-map.test.ts +++ b/test/model-map.test.ts @@ -1,172 +1,112 @@ -import { describe, it, expect } from "vitest"; -import { MODEL_MAP, getNormalizedModel, isKnownModel } from "../lib/request/helpers/model-map.js"; - -describe("Model Map Module", () => { - describe("MODEL_MAP", () => { - it("contains canonical GPT-5 codex mappings", () => { - expect(MODEL_MAP["gpt-5-codex"]).toBe("gpt-5-codex"); - expect(MODEL_MAP["gpt-5-codex-low"]).toBe("gpt-5-codex"); - expect(MODEL_MAP["gpt-5-codex-medium"]).toBe("gpt-5-codex"); - expect(MODEL_MAP["gpt-5-codex-high"]).toBe("gpt-5-codex"); - }); - - it("contains GPT-5.1 codex-max models", () => { - expect(MODEL_MAP["gpt-5.1-codex-max"]).toBe("gpt-5.1-codex-max"); - expect(MODEL_MAP["gpt-5.1-codex-max-low"]).toBe("gpt-5.1-codex-max"); - expect(MODEL_MAP["gpt-5.1-codex-max-medium"]).toBe("gpt-5.1-codex-max"); - expect(MODEL_MAP["gpt-5.1-codex-max-high"]).toBe("gpt-5.1-codex-max"); - expect(MODEL_MAP["gpt-5.1-codex-max-xhigh"]).toBe("gpt-5.1-codex-max"); - }); - - it("contains GPT-5.2 models", () => { - expect(MODEL_MAP["gpt-5.2"]).toBe("gpt-5.2"); - expect(MODEL_MAP["gpt-5.2-none"]).toBe("gpt-5.2"); - expect(MODEL_MAP["gpt-5.2-low"]).toBe("gpt-5.2"); - expect(MODEL_MAP["gpt-5.2-medium"]).toBe("gpt-5.2"); - expect(MODEL_MAP["gpt-5.2-high"]).toBe("gpt-5.2"); - expect(MODEL_MAP["gpt-5.2-xhigh"]).toBe("gpt-5.2"); - }); - - it("contains GPT-5.2 codex models", () => { - expect(MODEL_MAP["gpt-5.2-codex"]).toBe("gpt-5-codex"); - expect(MODEL_MAP["gpt-5.2-codex-low"]).toBe("gpt-5-codex"); - expect(MODEL_MAP["gpt-5.2-codex-medium"]).toBe("gpt-5-codex"); - expect(MODEL_MAP["gpt-5.2-codex-high"]).toBe("gpt-5-codex"); - expect(MODEL_MAP["gpt-5.2-codex-xhigh"]).toBe("gpt-5-codex"); - }); - - it("contains GPT-5.3 codex models", () => { - expect(MODEL_MAP["gpt-5.3-codex"]).toBe("gpt-5-codex"); - expect(MODEL_MAP["gpt-5.3-codex-low"]).toBe("gpt-5-codex"); - expect(MODEL_MAP["gpt-5.3-codex-medium"]).toBe("gpt-5-codex"); - expect(MODEL_MAP["gpt-5.3-codex-high"]).toBe("gpt-5-codex"); - expect(MODEL_MAP["gpt-5.3-codex-xhigh"]).toBe("gpt-5-codex"); - }); - - it("contains GPT-5.3 codex spark models", () => { - expect(MODEL_MAP["gpt-5.3-codex-spark"]).toBe("gpt-5-codex"); - expect(MODEL_MAP["gpt-5.3-codex-spark-low"]).toBe("gpt-5-codex"); - expect(MODEL_MAP["gpt-5.3-codex-spark-medium"]).toBe("gpt-5-codex"); - expect(MODEL_MAP["gpt-5.3-codex-spark-high"]).toBe("gpt-5-codex"); - expect(MODEL_MAP["gpt-5.3-codex-spark-xhigh"]).toBe("gpt-5-codex"); - }); - - it("contains GPT-5.1 codex-mini models", () => { - expect(MODEL_MAP["gpt-5.1-codex-mini"]).toBe("gpt-5.1-codex-mini"); - expect(MODEL_MAP["gpt-5.1-codex-mini-medium"]).toBe("gpt-5.1-codex-mini"); - expect(MODEL_MAP["gpt-5.1-codex-mini-high"]).toBe("gpt-5.1-codex-mini"); - }); - - it("contains GPT-5.1 general purpose models", () => { - expect(MODEL_MAP["gpt-5.1"]).toBe("gpt-5.1"); - expect(MODEL_MAP["gpt-5.1-none"]).toBe("gpt-5.1"); - expect(MODEL_MAP["gpt-5.1-low"]).toBe("gpt-5.1"); - expect(MODEL_MAP["gpt-5.1-medium"]).toBe("gpt-5.1"); - expect(MODEL_MAP["gpt-5.1-high"]).toBe("gpt-5.1"); - expect(MODEL_MAP["gpt-5.1-chat-latest"]).toBe("gpt-5.1"); - }); - - it("keeps canonical GPT-5 codex mapping stable", () => { - expect(MODEL_MAP["gpt-5-codex"]).toBe("gpt-5-codex"); - }); - - it("maps legacy codex-mini models to GPT-5.1 codex-mini", () => { - expect(MODEL_MAP["codex-mini-latest"]).toBe("gpt-5.1-codex-mini"); - expect(MODEL_MAP["gpt-5-codex-mini"]).toBe("gpt-5.1-codex-mini"); - expect(MODEL_MAP["gpt-5-codex-mini-medium"]).toBe("gpt-5.1-codex-mini"); - expect(MODEL_MAP["gpt-5-codex-mini-high"]).toBe("gpt-5.1-codex-mini"); - }); - - it("maps legacy GPT-5 general purpose models to GPT-5.1", () => { - expect(MODEL_MAP["gpt-5"]).toBe("gpt-5.1"); - expect(MODEL_MAP["gpt-5-mini"]).toBe("gpt-5.1"); - expect(MODEL_MAP["gpt-5-nano"]).toBe("gpt-5.1"); - }); - }); - - describe("getNormalizedModel", () => { - it("returns normalized model for exact match", () => { - expect(getNormalizedModel("gpt-5.1-codex")).toBe("gpt-5-codex"); - expect(getNormalizedModel("gpt-5.1-codex-low")).toBe("gpt-5-codex"); - expect(getNormalizedModel("gpt-5.2-codex-high")).toBe("gpt-5-codex"); - expect(getNormalizedModel("gpt-5.3-codex-high")).toBe("gpt-5-codex"); - expect(getNormalizedModel("gpt-5.3-codex-spark-high")).toBe("gpt-5-codex"); - }); - - it("handles case-insensitive lookup", () => { - expect(getNormalizedModel("GPT-5.1-CODEX")).toBe("gpt-5-codex"); - expect(getNormalizedModel("Gpt-5.2-Codex-High")).toBe("gpt-5-codex"); - expect(getNormalizedModel("Gpt-5.3-Codex-High")).toBe("gpt-5-codex"); - expect(getNormalizedModel("Gpt-5.3-Codex-Spark-High")).toBe("gpt-5-codex"); - }); - - it("returns undefined for unknown models", () => { - expect(getNormalizedModel("unknown-model")).toBeUndefined(); - expect(getNormalizedModel("gpt-6")).toBeUndefined(); - expect(getNormalizedModel("")).toBeUndefined(); - }); - - it("handles legacy model mapping", () => { - expect(getNormalizedModel("gpt-5-codex")).toBe("gpt-5-codex"); - expect(getNormalizedModel("gpt-5")).toBe("gpt-5.1"); - expect(getNormalizedModel("codex-mini-latest")).toBe("gpt-5.1-codex-mini"); - }); - - it("strips reasoning effort suffix and normalizes", () => { - expect(getNormalizedModel("gpt-5.1-codex-max-xhigh")).toBe("gpt-5.1-codex-max"); - expect(getNormalizedModel("gpt-5.2-medium")).toBe("gpt-5.2"); - }); - }); - - describe("isKnownModel", () => { - it("returns true for known models", () => { - expect(isKnownModel("gpt-5.1-codex")).toBe(true); - expect(isKnownModel("gpt-5.2")).toBe(true); - expect(isKnownModel("gpt-5.3-codex")).toBe(true); - expect(isKnownModel("gpt-5.3-codex-spark")).toBe(true); - expect(isKnownModel("gpt-5.1-codex-max")).toBe(true); - expect(isKnownModel("gpt-5-codex")).toBe(true); - }); - - it("returns true for case-insensitive matches", () => { - expect(isKnownModel("GPT-5.1-CODEX")).toBe(true); - expect(isKnownModel("GPT-5.2-CODEX-HIGH")).toBe(true); - expect(isKnownModel("GPT-5.3-CODEX-HIGH")).toBe(true); - expect(isKnownModel("GPT-5.3-CODEX-SPARK-HIGH")).toBe(true); - }); - - it("returns false for unknown models", () => { - expect(isKnownModel("gpt-6")).toBe(false); - expect(isKnownModel("claude-3")).toBe(false); - expect(isKnownModel("unknown")).toBe(false); - expect(isKnownModel("")).toBe(false); - }); - }); - - describe("Model count and completeness", () => { - it("has expected number of model mappings", () => { - const modelCount = Object.keys(MODEL_MAP).length; - expect(modelCount).toBeGreaterThanOrEqual(30); - }); - - it("all values are valid normalized model names", () => { - const validNormalizedModels = new Set([ - "gpt-5-codex", - "gpt-5.1-codex-max", - "gpt-5.1-codex-mini", - "gpt-5.1", - "gpt-5.2", - ]); - - for (const [key, value] of Object.entries(MODEL_MAP)) { - expect(validNormalizedModels.has(value)).toBe(true); - } - }); - - it("no duplicate keys exist", () => { - const keys = Object.keys(MODEL_MAP); - const uniqueKeys = new Set(keys); - expect(keys.length).toBe(uniqueKeys.size); - }); - }); +import { describe, expect, it } from "vitest"; +import { + DEFAULT_MODEL, + MODEL_MAP, + getModelCapabilities, + getModelProfile, + getNormalizedModel, + isKnownModel, + resolveNormalizedModel, +} from "../lib/request/helpers/model-map.js"; + +describe("model map", () => { + describe("MODEL_MAP", () => { + it("keeps codex families canonical", () => { + expect(MODEL_MAP["gpt-5-codex"]).toBe("gpt-5-codex"); + expect(MODEL_MAP["gpt-5.3-codex-spark-high"]).toBe("gpt-5-codex"); + expect(MODEL_MAP["gpt-5.1-codex-max-xhigh"]).toBe("gpt-5.1-codex-max"); + expect(MODEL_MAP["codex-mini-latest"]).toBe("gpt-5.1-codex-mini"); + }); + + it("adds first-class GPT-5.4 era general models", () => { + expect(MODEL_MAP["gpt-5.4"]).toBe("gpt-5.4"); + expect(MODEL_MAP["gpt-5.4-pro-high"]).toBe("gpt-5.4-pro"); + expect(MODEL_MAP["gpt-5"]).toBe("gpt-5"); + expect(MODEL_MAP["gpt-5-pro-high"]).toBe("gpt-5-pro"); + }); + + it("keeps mini and nano on current non-5.1 model IDs", () => { + expect(MODEL_MAP["gpt-5-mini"]).toBe("gpt-5-mini"); + expect(MODEL_MAP["gpt-5-nano"]).toBe("gpt-5-nano"); + expect(MODEL_MAP["gpt-5.4-mini"]).toBe("gpt-5-mini"); + expect(MODEL_MAP["gpt-5.4-nano"]).toBe("gpt-5-nano"); + }); + }); + + describe("getNormalizedModel", () => { + it("returns exact aliases case-insensitively", () => { + expect(getNormalizedModel("GPT-5.4")).toBe("gpt-5.4"); + expect(getNormalizedModel("GPT-5.4-PRO-HIGH")).toBe("gpt-5.4-pro"); + expect(getNormalizedModel("gpt-5.4-mini")).toBe("gpt-5-mini"); + expect(getNormalizedModel("gpt-5.3-codex-high")).toBe("gpt-5-codex"); + }); + + it("returns undefined for unknown exact identifiers", () => { + expect(getNormalizedModel("unknown-model")).toBeUndefined(); + expect(getNormalizedModel("gpt-6")).toBeUndefined(); + expect(getNormalizedModel("")).toBeUndefined(); + }); + }); + + describe("resolveNormalizedModel", () => { + it("resolves provider-prefixed and verbose GPT-5 variants", () => { + expect(resolveNormalizedModel("openai/gpt-5.4")).toBe("gpt-5.4"); + expect(resolveNormalizedModel("openai/gpt-5.4-mini-high")).toBe("gpt-5-mini"); + expect(resolveNormalizedModel("GPT 5.4 Pro High")).toBe("gpt-5.4-pro"); + expect(resolveNormalizedModel("GPT 5 Codex Low (ChatGPT Subscription)")).toBe("gpt-5-codex"); + }); + + it("defaults unknown GPT-5-ish requests to GPT-5.4 instead of GPT-5.1", () => { + expect(resolveNormalizedModel("gpt-5-unknown-preview")).toBe("gpt-5.4"); + expect(resolveNormalizedModel("gpt 5 experimental build")).toBe("gpt-5.4"); + }); + + it("uses the current default model when the request is missing or unrelated", () => { + expect(resolveNormalizedModel(undefined)).toBe(DEFAULT_MODEL); + expect(resolveNormalizedModel("")).toBe(DEFAULT_MODEL); + expect(resolveNormalizedModel("gpt-4")).toBe(DEFAULT_MODEL); + expect(resolveNormalizedModel("unknown-model")).toBe(DEFAULT_MODEL); + }); + }); + + describe("model profiles", () => { + it("routes GPT-5.4-era general models through the latest available general prompt family", () => { + expect(getModelProfile("gpt-5.4").promptFamily).toBe("gpt-5.2"); + expect(getModelProfile("gpt-5.4-pro").promptFamily).toBe("gpt-5.2"); + expect(getModelProfile("gpt-5-mini").promptFamily).toBe("gpt-5.2"); + }); + + it("keeps GPT-5.1 on its own prompt family", () => { + expect(getModelProfile("gpt-5.1").promptFamily).toBe("gpt-5.1"); + }); + + it("exposes tool-search and computer-use capabilities", () => { + expect(getModelCapabilities("gpt-5.4")).toEqual({ + toolSearch: true, + computerUse: true, + }); + expect(getModelCapabilities("gpt-5.4-pro")).toEqual({ + toolSearch: false, + computerUse: true, + }); + expect(getModelCapabilities("gpt-5-mini")).toEqual({ + toolSearch: false, + computerUse: false, + }); + }); + }); + + describe("isKnownModel", () => { + it("returns true for explicit aliases only", () => { + expect(isKnownModel("gpt-5.4")).toBe(true); + expect(isKnownModel("gpt-5.4-mini")).toBe(true); + expect(isKnownModel("GPT-5.3-CODEX-HIGH")).toBe(true); + }); + + it("returns false for unknown names even though fallback routing exists", () => { + expect(isKnownModel("gpt-5-unknown-preview")).toBe(false); + expect(isKnownModel("claude-3")).toBe(false); + expect(isKnownModel("")).toBe(false); + }); + }); }); diff --git a/test/property/transformer.property.test.ts b/test/property/transformer.property.test.ts index 10a1454d..98fe302e 100644 --- a/test/property/transformer.property.test.ts +++ b/test/property/transformer.property.test.ts @@ -47,12 +47,12 @@ describe("normalizeModel property tests", () => { it("handles undefined gracefully", () => { const result = normalizeModel(undefined); - expect(result).toBe("gpt-5.1"); + expect(result).toBe("gpt-5.4"); }); it("handles empty string gracefully", () => { const result = normalizeModel(""); - expect(result).toBe("gpt-5.1"); + expect(result).toBe("gpt-5.4"); }); }); @@ -203,10 +203,10 @@ describe("getReasoningConfig property tests", () => { ); }); - it("non-xhigh models downgrade xhigh to high", () => { + it("models without xhigh support downgrade xhigh to high", () => { fc.assert( fc.property( - fc.constantFrom("gpt-5.1", "gpt-5.1-codex"), + fc.constantFrom("gpt-5", "gpt-5.1"), (model) => { const result = getReasoningConfig(model, { reasoningEffort: "xhigh" }); expect(result.effort).toBe("high"); diff --git a/test/request-transformer.test.ts b/test/request-transformer.test.ts index b53ca89b..0dd01959 100644 --- a/test/request-transformer.test.ts +++ b/test/request-transformer.test.ts @@ -16,143 +16,48 @@ import type { RequestBody, UserConfig, InputItem } from '../lib/types.js'; describe('Request Transformer Module', () => { describe('normalizeModel', () => { - // NOTE: All gpt-5 models now normalize to gpt-5.1 as gpt-5 is being phased out - it('should normalize gpt-5-codex to gpt-5.1-codex', async () => { + it('keeps codex families canonical', async () => { expect(normalizeModel('gpt-5-codex')).toBe('gpt-5-codex'); - }); - - it('should normalize gpt-5 to gpt-5.1', async () => { - expect(normalizeModel('gpt-5')).toBe('gpt-5.1'); - }); - - it('should normalize variants containing "codex" to gpt-5.1-codex', async () => { expect(normalizeModel('openai/gpt-5-codex')).toBe('gpt-5-codex'); - expect(normalizeModel('custom-gpt-5-codex-variant')).toBe('gpt-5-codex'); + expect(normalizeModel('gpt-5.3-codex-spark-high')).toBe('gpt-5-codex'); + expect(normalizeModel('gpt-5.1-codex-max-high')).toBe('gpt-5.1-codex-max'); + expect(normalizeModel('codex-mini-latest')).toBe('gpt-5.1-codex-mini'); }); - it('should normalize variants containing "gpt-5" to gpt-5.1', async () => { - expect(normalizeModel('gpt-5-mini')).toBe('gpt-5.1'); - expect(normalizeModel('gpt-5-nano')).toBe('gpt-5.1'); + it('keeps GPT-5.4 era general models first-class', async () => { + expect(normalizeModel('gpt-5.4')).toBe('gpt-5.4'); + expect(normalizeModel('gpt-5.4-pro-high')).toBe('gpt-5.4-pro'); + expect(normalizeModel('gpt-5')).toBe('gpt-5'); + expect(normalizeModel('gpt-5-pro-high')).toBe('gpt-5-pro'); }); - it('should return gpt-5.1 as default for unknown models', async () => { - expect(normalizeModel('unknown-model')).toBe('gpt-5.1'); - expect(normalizeModel('gpt-4')).toBe('gpt-5.1'); + it('maps GPT-5.4 mini and nano aliases onto the current small-model IDs', async () => { + expect(normalizeModel('gpt-5.4-mini')).toBe('gpt-5-mini'); + expect(normalizeModel('gpt-5.4-mini-high')).toBe('gpt-5-mini'); + expect(normalizeModel('gpt-5.4-nano')).toBe('gpt-5-nano'); + expect(normalizeModel('gpt-5.4-nano-high')).toBe('gpt-5-nano'); + expect(normalizeModel('gpt-5-mini')).toBe('gpt-5-mini'); + expect(normalizeModel('gpt-5-nano')).toBe('gpt-5-nano'); }); - it('should return gpt-5.1 for undefined', async () => { - expect(normalizeModel(undefined)).toBe('gpt-5.1'); + it('defaults unknown requests to GPT-5.4 instead of GPT-5.1', async () => { + expect(normalizeModel('unknown-model')).toBe('gpt-5.4'); + expect(normalizeModel('gpt-4')).toBe('gpt-5.4'); + expect(normalizeModel(undefined)).toBe('gpt-5.4'); + expect(normalizeModel('')).toBe('gpt-5.4'); }); - // Codex CLI preset name tests - legacy gpt-5 models now map to gpt-5.1 - describe('Codex CLI preset names', () => { - it('should normalize all gpt-5-codex presets to gpt-5.1-codex', async () => { - expect(normalizeModel('gpt-5-codex-low')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt-5-codex-medium')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt-5-codex-high')).toBe('gpt-5-codex'); - }); - - it('should normalize all gpt-5 presets to gpt-5.1', async () => { - expect(normalizeModel('gpt-5-minimal')).toBe('gpt-5.1'); - expect(normalizeModel('gpt-5-low')).toBe('gpt-5.1'); - expect(normalizeModel('gpt-5-medium')).toBe('gpt-5.1'); - expect(normalizeModel('gpt-5-high')).toBe('gpt-5.1'); - }); - - it('should prioritize codex over gpt-5 in model name', async () => { - // Model name contains BOTH "codex" and "gpt-5" - // Should return "gpt-5.1-codex" (codex checked first, maps to 5.1) - expect(normalizeModel('gpt-5-codex-low')).toBe('gpt-5-codex'); - expect(normalizeModel('my-gpt-5-codex-model')).toBe('gpt-5-codex'); - }); - - it('should normalize codex mini presets to gpt-5.1-codex-mini', async () => { - expect(normalizeModel('gpt-5-codex-mini')).toBe('gpt-5.1-codex-mini'); - expect(normalizeModel('gpt-5-codex-mini-low')).toBe('gpt-5.1-codex-mini'); - expect(normalizeModel('gpt-5-codex-mini-medium')).toBe('gpt-5.1-codex-mini'); - expect(normalizeModel('gpt-5-codex-mini-high')).toBe('gpt-5.1-codex-mini'); - expect(normalizeModel('openai/gpt-5-codex-mini-high')).toBe('gpt-5.1-codex-mini'); - expect(normalizeModel('codex-mini-latest')).toBe('gpt-5.1-codex-mini'); - expect(normalizeModel('openai/codex-mini-latest')).toBe('gpt-5.1-codex-mini'); - }); - - it('should normalize gpt-5.1 codex max presets', async () => { - expect(normalizeModel('gpt-5.1-codex-max')).toBe('gpt-5.1-codex-max'); - expect(normalizeModel('gpt-5.1-codex-max-high')).toBe('gpt-5.1-codex-max'); - expect(normalizeModel('gpt-5.1-codex-max-xhigh')).toBe('gpt-5.1-codex-max'); - expect(normalizeModel('openai/gpt-5.1-codex-max-medium')).toBe('gpt-5.1-codex-max'); - }); - - it('should normalize gpt-5.2 codex presets', async () => { - expect(normalizeModel('gpt-5.2-codex')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt-5.2-codex-low')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt-5.2-codex-medium')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt-5.2-codex-high')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt-5.2-codex-xhigh')).toBe('gpt-5-codex'); - expect(normalizeModel('openai/gpt-5.2-codex-xhigh')).toBe('gpt-5-codex'); - }); - - it('should normalize gpt-5.3 codex presets', async () => { - expect(normalizeModel('gpt-5.3-codex')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt-5.3-codex-low')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt-5.3-codex-medium')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt-5.3-codex-high')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt-5.3-codex-xhigh')).toBe('gpt-5-codex'); - expect(normalizeModel('openai/gpt-5.3-codex-xhigh')).toBe('gpt-5-codex'); - }); - - it('should normalize gpt-5.3 codex spark presets', async () => { - expect(normalizeModel('gpt-5.3-codex-spark')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt-5.3-codex-spark-low')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt-5.3-codex-spark-medium')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt-5.3-codex-spark-high')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt-5.3-codex-spark-xhigh')).toBe('gpt-5-codex'); - expect(normalizeModel('openai/gpt-5.3-codex-spark-xhigh')).toBe('gpt-5-codex'); - }); - - it('should normalize gpt-5.1 codex and mini slugs', async () => { - expect(normalizeModel('gpt-5.1-codex')).toBe('gpt-5-codex'); - expect(normalizeModel('openai/gpt-5.1-codex')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt-5.1-codex-mini')).toBe('gpt-5.1-codex-mini'); - expect(normalizeModel('gpt-5.1-codex-mini-low')).toBe('gpt-5.1-codex-mini'); - expect(normalizeModel('gpt-5.1-codex-mini-high')).toBe('gpt-5.1-codex-mini'); - expect(normalizeModel('openai/gpt-5.1-codex-mini-medium')).toBe('gpt-5.1-codex-mini'); - }); - - it('should normalize gpt-5.1 general-purpose slugs', async () => { - expect(normalizeModel('gpt-5.1')).toBe('gpt-5.1'); - expect(normalizeModel('openai/gpt-5.1')).toBe('gpt-5.1'); - expect(normalizeModel('GPT 5.1 High')).toBe('gpt-5.1'); - }); + it('still prioritizes codex detection when model names contain both codex and GPT-5', async () => { + expect(normalizeModel('gpt-5-codex-low')).toBe('gpt-5-codex'); + expect(normalizeModel('my-gpt-5-codex-model')).toBe('gpt-5-codex'); }); - // Edge case tests - legacy gpt-5 models now map to gpt-5.1 - describe('Edge cases', () => { - it('should handle uppercase model names', async () => { - expect(normalizeModel('GPT-5-CODEX')).toBe('gpt-5-codex'); - expect(normalizeModel('GPT-5-HIGH')).toBe('gpt-5.1'); - expect(normalizeModel('CODEx-MINI-LATEST')).toBe('gpt-5.1-codex-mini'); - expect(normalizeModel('GPT-5.3-CODEX-SPARK')).toBe('gpt-5-codex'); - }); - - it('should handle mixed case', async () => { - expect(normalizeModel('Gpt-5-Codex-Low')).toBe('gpt-5-codex'); - expect(normalizeModel('GpT-5-MeDiUm')).toBe('gpt-5.1'); - }); - - it('should handle special characters', async () => { - expect(normalizeModel('my_gpt-5_codex')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt.5.high')).toBe('gpt-5.1'); - }); - - it('should handle old verbose names', async () => { - expect(normalizeModel('GPT 5 Codex Low (ChatGPT Subscription)')).toBe('gpt-5-codex'); - expect(normalizeModel('GPT 5 High (ChatGPT Subscription)')).toBe('gpt-5.1'); - }); - - it('should handle empty string', async () => { - expect(normalizeModel('')).toBe('gpt-5.1'); - }); + it('handles case and formatting variations', async () => { + expect(normalizeModel('GPT-5.4')).toBe('gpt-5.4'); + expect(normalizeModel('GPT-5-HIGH')).toBe('gpt-5'); + expect(normalizeModel('Gpt-5.4-Pro')).toBe('gpt-5.4-pro'); + expect(normalizeModel('GPT 5 High (ChatGPT Subscription)')).toBe('gpt-5.4'); + expect(normalizeModel('GPT 5 Codex Low (ChatGPT Subscription)')).toBe('gpt-5-codex'); }); }); @@ -725,7 +630,7 @@ describe('Request Transformer Module', () => { input: [], }; const result = await transformRequestBody(body, codexInstructions); - expect(result.model).toBe('gpt-5.1'); // gpt-5 now maps to gpt-5.1 + expect(result.model).toBe('gpt-5-mini'); }); it('should apply default reasoning config', async () => { @@ -802,7 +707,7 @@ describe('Request Transformer Module', () => { 'hybrid', ); - expect(result.reasoning?.effort).toBe('high'); + expect(result.reasoning?.effort).toBe('xhigh'); expect(result.reasoning?.summary).toBe('detailed'); expect(result.text?.verbosity).toBe('high'); }); @@ -1066,7 +971,7 @@ describe('Request Transformer Module', () => { 'hybrid', ); - expect(result.reasoning?.effort).toBe('high'); + expect(result.reasoning?.effort).toBe('xhigh'); expect(result.reasoning?.summary).toBe('detailed'); expect(result.text?.verbosity).toBe('high'); expect(result.tools).toEqual([{ type: 'function', function: { name: 'read_file' } }]); @@ -1474,7 +1379,7 @@ describe('Request Transformer Module', () => { expect(result.reasoning?.summary).toBe('detailed'); }); - it('should downgrade requested xhigh to high for gpt-5.2-codex', async () => { + it('should preserve requested xhigh for gpt-5.2-codex', async () => { const body: RequestBody = { model: 'gpt-5.2-codex-xhigh', input: [], @@ -1489,11 +1394,11 @@ describe('Request Transformer Module', () => { }; const result = await transformRequestBody(body, codexInstructions, userConfig); expect(result.model).toBe('gpt-5-codex'); - expect(result.reasoning?.effort).toBe('high'); + expect(result.reasoning?.effort).toBe('xhigh'); expect(result.reasoning?.summary).toBe('detailed'); }); - it('should downgrade requested xhigh to high for gpt-5.3-codex', async () => { + it('should preserve requested xhigh for gpt-5.3-codex', async () => { const body: RequestBody = { model: 'gpt-5.3-codex-xhigh', input: [], @@ -1508,11 +1413,11 @@ describe('Request Transformer Module', () => { }; const result = await transformRequestBody(body, codexInstructions, userConfig); expect(result.model).toBe('gpt-5-codex'); - expect(result.reasoning?.effort).toBe('high'); + expect(result.reasoning?.effort).toBe('xhigh'); expect(result.reasoning?.summary).toBe('detailed'); }, 10_000); - it('should downgrade xhigh to high for non-max codex', async () => { + it('should preserve xhigh for non-max codex when the normalized model supports it', async () => { const body: RequestBody = { model: 'gpt-5.1-codex-high', input: [], @@ -1523,7 +1428,7 @@ describe('Request Transformer Module', () => { }; const result = await transformRequestBody(body, codexInstructions, userConfig); expect(result.model).toBe('gpt-5-codex'); - expect(result.reasoning?.effort).toBe('high'); + expect(result.reasoning?.effort).toBe('xhigh'); }); it('should downgrade xhigh to high for non-max general models', async () => { @@ -1652,7 +1557,7 @@ describe('Request Transformer Module', () => { expect(result.reasoning?.effort).toBe('low'); }); - it('should upgrade none to low for GPT-5.1-codex-max (codex max does not support none)', async () => { + it('should upgrade none to medium for GPT-5.1-codex-max (codex max does not support none)', async () => { const body: RequestBody = { model: 'gpt-5.1-codex-max', input: [], @@ -1663,7 +1568,7 @@ describe('Request Transformer Module', () => { }; const result = await transformRequestBody(body, codexInstructions, userConfig); expect(result.model).toBe('gpt-5.1-codex-max'); - expect(result.reasoning?.effort).toBe('low'); + expect(result.reasoning?.effort).toBe('medium'); }); it('should preserve minimal for non-codex models', async () => { @@ -1685,7 +1590,7 @@ describe('Request Transformer Module', () => { input: [], }; const result = await transformRequestBody(body, codexInstructions); - expect(result.reasoning?.effort).toBe('minimal'); + expect(result.reasoning?.effort).toBe('medium'); }); it('should convert orphaned function_call_output to message to preserve context', async () => { @@ -1951,7 +1856,7 @@ describe('Request Transformer Module', () => { expect(result.store).toBe(false); }); - it('should handle gpt-5-mini normalizing to gpt-5.1', async () => { + it('should handle gpt-5-mini without silently downgrading it to gpt-5.1', async () => { const body: RequestBody = { model: 'gpt-5-mini', input: [] @@ -1959,8 +1864,8 @@ describe('Request Transformer Module', () => { const result = await transformRequestBody(body, codexInstructions); - expect(result.model).toBe('gpt-5.1'); // gpt-5 now maps to gpt-5.1 - expect(result.reasoning?.effort).toBe('minimal'); // Lightweight gpt-5-mini defaults to minimal + expect(result.model).toBe('gpt-5-mini'); + expect(result.reasoning?.effort).toBe('medium'); }); }); From 173d64f3af66a4521945be0fc8ab5ad5dba22fa3 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 15:54:10 +0800 Subject: [PATCH 308/376] add responses continuation request contract support --- index.ts | 37 ++++++++- lib/config.ts | 19 +++++ lib/request/fetch-helpers.ts | 17 ++-- lib/request/response-handler.ts | 128 ++++++++++++++++++++++++++++--- lib/schemas.ts | 1 + lib/session-affinity.ts | 44 +++++++++++ lib/types.ts | 27 +++++++ test/index.test.ts | 122 ++++++++++++++++++++++++++++- test/plugin-config.test.ts | 26 +++++++ test/request-transformer.test.ts | 46 +++++++++++ test/response-handler.test.ts | 41 +++++++++- test/session-affinity.test.ts | 20 +++++ 12 files changed, 504 insertions(+), 24 deletions(-) diff --git a/index.ts b/index.ts index 368daaf3..98d56bdd 100644 --- a/index.ts +++ b/index.ts @@ -65,6 +65,7 @@ import { getLiveAccountSync, getLiveAccountSyncDebounceMs, getLiveAccountSyncPollMs, + getResponseContinuation, getSessionAffinity, getSessionAffinityTtlMs, getSessionAffinityMaxEntries, @@ -1336,7 +1337,27 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const originalBody = await parseRequestBodyFromInit(baseInit?.body); const isStreaming = originalBody.stream === true; const parsedBody = - Object.keys(originalBody).length > 0 ? originalBody : undefined; + Object.keys(originalBody).length > 0 ? { ...originalBody } : undefined; + const requestPromptCacheKey = + typeof parsedBody?.prompt_cache_key === "string" + ? parsedBody.prompt_cache_key.trim() + : ""; + const requestThreadId = + (process.env.CODEX_THREAD_ID ?? requestPromptCacheKey ?? "") + .toString() + .trim() || undefined; + const continuationSessionKey = requestThreadId ?? requestPromptCacheKey ?? null; + const shouldUseResponseContinuation = + Boolean(parsedBody) && + getResponseContinuation(pluginConfig) && + !parsedBody?.previous_response_id; + if (shouldUseResponseContinuation) { + const lastResponseId = + sessionAffinityStore?.getLastResponseId(continuationSessionKey); + if (lastResponseId && parsedBody) { + parsedBody.previous_response_id = lastResponseId; + } + } const transformation = await transformRequestForCodex( baseInit, @@ -2447,7 +2468,15 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { }, ); } + let capturedResponseId: string | null = null; const successResponse = await handleSuccessResponse(responseForSuccess, isStreaming, { + onResponseId: (responseId) => { + capturedResponseId = responseId; + sessionAffinityStore?.rememberLastResponseId( + sessionAffinityKey, + responseId, + ); + }, streamStallTimeoutMs, }); @@ -2516,6 +2545,12 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { sessionAffinityKey, successAccountForResponse.index, ); + if (capturedResponseId) { + sessionAffinityStore?.rememberLastResponseId( + sessionAffinityKey, + capturedResponseId, + ); + } runtimeMetrics.successfulRequests++; runtimeMetrics.lastError = null; if (lastCodexCliActiveSyncIndex !== successAccountForResponse.index) { diff --git a/lib/config.ts b/lib/config.ts index f9e7ecf8..910ed14b 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -148,6 +148,7 @@ export const DEFAULT_PLUGIN_CONFIG: PluginConfig = { sessionAffinity: true, sessionAffinityTtlMs: 20 * 60_000, sessionAffinityMaxEntries: 512, + responseContinuation: false, proactiveRefreshGuardian: true, proactiveRefreshIntervalMs: 60_000, proactiveRefreshBufferMs: 5 * 60_000, @@ -917,6 +918,24 @@ export function getSessionAffinityMaxEntries(pluginConfig: PluginConfig): number ); } +/** + * Controls whether the plugin should automatically continue Responses API turns + * with the last known `previous_response_id` for the active session key. + * + * Reads the `responseContinuation` value from `pluginConfig` and allows an + * environment override via `CODEX_AUTH_RESPONSE_CONTINUATION`. + * + * @param pluginConfig - The plugin configuration to consult for the setting + * @returns `true` if automatic response continuation is enabled, `false` otherwise + */ +export function getResponseContinuation(pluginConfig: PluginConfig): boolean { + return resolveBooleanSetting( + "CODEX_AUTH_RESPONSE_CONTINUATION", + pluginConfig.responseContinuation, + false, + ); +} + /** * Controls whether the proactive refresh guardian is enabled. * diff --git a/lib/request/fetch-helpers.ts b/lib/request/fetch-helpers.ts index 4581b618..37043418 100644 --- a/lib/request/fetch-helpers.ts +++ b/lib/request/fetch-helpers.ts @@ -9,7 +9,11 @@ import { queuedRefresh } from "../refresh-queue.js"; import { logRequest, logError, logWarn } from "../logger.js"; import { getCodexInstructions, getModelFamily } from "../prompts/codex.js"; import { transformRequestBody, normalizeModel } from "./request-transformer.js"; -import { convertSseToJson, ensureContentType } from "./response-handler.js"; +import { + attachResponseIdCapture, + convertSseToJson, + ensureContentType, +} from "./response-handler.js"; import type { UserConfig, RequestBody } from "../types.js"; import { registerCleanup } from "../shutdown.js"; import { CodexAuthError } from "../errors.js"; @@ -841,7 +845,10 @@ export async function handleErrorResponse( export async function handleSuccessResponse( response: Response, isStreaming: boolean, - options?: { streamStallTimeoutMs?: number }, + options?: { + onResponseId?: (responseId: string) => void; + streamStallTimeoutMs?: number; + }, ): Promise { // Check for deprecation headers (RFC 8594) const deprecation = response.headers.get("Deprecation"); @@ -858,11 +865,7 @@ export async function handleSuccessResponse( } // For streaming requests (streamText), return stream as-is - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers: responseHeaders, - }); + return attachResponseIdCapture(response, responseHeaders, options?.onResponseId); } async function safeReadBody(response: Response): Promise { diff --git a/lib/request/response-handler.ts b/lib/request/response-handler.ts index 76de00f6..b8db279b 100644 --- a/lib/request/response-handler.ts +++ b/lib/request/response-handler.ts @@ -8,13 +8,57 @@ const log = createLogger("response-handler"); const MAX_SSE_SIZE = 10 * 1024 * 1024; // 10MB limit to prevent memory exhaustion const DEFAULT_STREAM_STALL_TIMEOUT_MS = 45_000; +function extractResponseId(response: unknown): string | null { + if (!response || typeof response !== "object") return null; + const candidate = (response as { id?: unknown }).id; + return typeof candidate === "string" && candidate.trim().length > 0 + ? candidate.trim() + : null; +} + +function notifyResponseId( + onResponseId: ((responseId: string) => void) | undefined, + response: unknown, +): void { + const responseId = extractResponseId(response); + if (!responseId || !onResponseId) return; + try { + onResponseId(responseId); + } catch (error) { + log.warn("Failed to persist response id from upstream event", { + error: String(error), + responseId, + }); + } +} + +function maybeCaptureResponseEvent( + data: SSEEventData, + onResponseId?: (responseId: string) => void, +): unknown | null { + if (data.type === "error") { + log.error("SSE error event received", { error: data }); + return null; + } + + if (data.type === "response.done" || data.type === "response.completed") { + notifyResponseId(onResponseId, data.response); + return data.response ?? null; + } + + return null; +} + /** * Parse SSE stream to extract final response * @param sseText - Complete SSE stream text * @returns Final response object or null if not found */ -function parseSseStream(sseText: string): unknown | null { +function parseSseStream( + sseText: string, + onResponseId?: (responseId: string) => void, +): unknown | null { const lines = sseText.split(/\r?\n/); for (const line of lines) { @@ -24,15 +68,8 @@ function parseSseStream(sseText: string): unknown | null { if (!payload || payload === '[DONE]') continue; try { const data = JSON.parse(payload) as SSEEventData; - - if (data.type === 'error') { - log.error("SSE error event received", { error: data }); - return null; - } - - if (data.type === 'response.done' || data.type === 'response.completed') { - return data.response; - } + const finalResponse = maybeCaptureResponseEvent(data, onResponseId); + if (finalResponse) return finalResponse; } catch { // Skip malformed JSON } @@ -51,7 +88,10 @@ function parseSseStream(sseText: string): unknown | null { export async function convertSseToJson( response: Response, headers: Headers, - options?: { streamStallTimeoutMs?: number }, + options?: { + onResponseId?: (responseId: string) => void; + streamStallTimeoutMs?: number; + }, ): Promise { if (!response.body) { throw new Error(`[${PLUGIN_NAME}] Response has no body`); @@ -80,7 +120,7 @@ export async function convertSseToJson( } // Parse SSE events to extract the final response - const finalResponse = parseSseStream(fullText); + const finalResponse = parseSseStream(fullText, options?.onResponseId); if (!finalResponse) { log.warn("Could not find final response in SSE stream"); @@ -119,6 +159,50 @@ export async function convertSseToJson( } +function createResponseIdCapturingStream( + body: ReadableStream, + onResponseId: (responseId: string) => void, +): ReadableStream { + const decoder = new TextDecoder(); + let bufferedText = ""; + + const processBufferedLines = (flush = false): void => { + const lines = bufferedText.split(/\r?\n/); + if (!flush) { + bufferedText = lines.pop() ?? ""; + } else { + bufferedText = ""; + } + + for (const rawLine of lines) { + const trimmedLine = rawLine.trim(); + if (!trimmedLine.startsWith("data: ")) continue; + const payload = trimmedLine.slice(6).trim(); + if (!payload || payload === "[DONE]") continue; + try { + const data = JSON.parse(payload) as SSEEventData; + maybeCaptureResponseEvent(data, onResponseId); + } catch { + // Ignore malformed SSE lines and keep forwarding the raw stream. + } + } + }; + + return body.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + bufferedText += decoder.decode(chunk, { stream: true }); + processBufferedLines(); + controller.enqueue(chunk); + }, + flush() { + bufferedText += decoder.decode(); + processBufferedLines(true); + }, + }), + ); +} + /** * Ensure response has content-type header * @param headers - Response headers @@ -186,3 +270,23 @@ export function isEmptyResponse(body: unknown): boolean { return false; } + +export function attachResponseIdCapture( + response: Response, + headers: Headers, + onResponseId?: (responseId: string) => void, +): Response { + if (!response.body || !onResponseId) { + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); + } + + return new Response(createResponseIdCapturingStream(response.body, onResponseId), { + status: response.status, + statusText: response.statusText, + headers, + }); +} diff --git a/lib/schemas.ts b/lib/schemas.ts index 1ab18caa..41585678 100644 --- a/lib/schemas.ts +++ b/lib/schemas.ts @@ -47,6 +47,7 @@ export const PluginConfigSchema = z.object({ sessionAffinity: z.boolean().optional(), sessionAffinityTtlMs: z.number().min(1_000).optional(), sessionAffinityMaxEntries: z.number().min(8).optional(), + responseContinuation: z.boolean().optional(), proactiveRefreshGuardian: z.boolean().optional(), proactiveRefreshIntervalMs: z.number().min(5_000).optional(), proactiveRefreshBufferMs: z.number().min(30_000).optional(), diff --git a/lib/session-affinity.ts b/lib/session-affinity.ts index 5cfcdafa..e907e776 100644 --- a/lib/session-affinity.ts +++ b/lib/session-affinity.ts @@ -10,6 +10,7 @@ export interface SessionAffinityOptions { interface SessionAffinityEntry { accountIndex: number; expiresAt: number; + lastResponseId?: string; updatedAt: number; } @@ -65,6 +66,8 @@ export class SessionAffinityStore { if (!key) return; if (!Number.isFinite(accountIndex) || accountIndex < 0) return; + const existingEntry = this.entries.get(key); + if (this.entries.size >= this.maxEntries && !this.entries.has(key)) { const oldest = this.findOldestKey(); if (oldest) this.entries.delete(oldest); @@ -73,6 +76,47 @@ export class SessionAffinityStore { this.entries.set(key, { accountIndex, expiresAt: now + this.ttlMs, + lastResponseId: existingEntry?.lastResponseId, + updatedAt: now, + }); + } + + getLastResponseId(sessionKey: string | null | undefined, now = Date.now()): string | null { + const key = normalizeSessionKey(sessionKey); + if (!key) return null; + + const entry = this.entries.get(key); + if (!entry) return null; + if (entry.expiresAt <= now) { + this.entries.delete(key); + return null; + } + + const lastResponseId = + typeof entry.lastResponseId === "string" ? entry.lastResponseId.trim() : ""; + return lastResponseId || null; + } + + rememberLastResponseId( + sessionKey: string | null | undefined, + responseId: string | null | undefined, + now = Date.now(), + ): void { + const key = normalizeSessionKey(sessionKey); + const normalizedResponseId = typeof responseId === "string" ? responseId.trim() : ""; + if (!key || !normalizedResponseId) return; + + const entry = this.entries.get(key); + if (!entry) return; + if (entry.expiresAt <= now) { + this.entries.delete(key); + return; + } + + this.entries.set(key, { + ...entry, + expiresAt: now + this.ttlMs, + lastResponseId: normalizedResponseId, updatedAt: now, }); } diff --git a/lib/types.ts b/lib/types.ts index fbe8e667..1feeb8a9 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -32,6 +32,28 @@ export interface ReasoningConfig { summary: "auto" | "concise" | "detailed"; } +export type TextFormatConfig = + | { + type: "text"; + [key: string]: unknown; + } + | { + type: "json_object"; + [key: string]: unknown; + } + | { + type: "json_schema"; + name?: string; + description?: string; + schema?: Record; + strict?: boolean; + [key: string]: unknown; + } + | { + type: string; + [key: string]: unknown; + }; + export interface OAuthServerInfo { port: number; ready: boolean; @@ -99,6 +121,7 @@ export interface RequestBody { reasoning?: Partial; text?: { verbosity?: "low" | "medium" | "high"; + format?: TextFormatConfig; }; include?: string[]; providerOptions?: { @@ -107,6 +130,10 @@ export interface RequestBody { }; /** Stable key to enable prompt-token caching on Codex backend */ prompt_cache_key?: string; + /** Retention mode for server-side prompt cache entries */ + prompt_cache_retention?: string; + /** Resume a prior Responses API turn without resending the full transcript */ + previous_response_id?: string; max_output_tokens?: number; max_completion_tokens?: number; [key: string]: unknown; diff --git a/test/index.test.ts b/test/index.test.ts index fa56f49d..fb89f4e6 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -96,9 +96,10 @@ vi.mock("../lib/config.js", () => ({ getLiveAccountSync: vi.fn(() => false), getLiveAccountSyncDebounceMs: () => 250, getLiveAccountSyncPollMs: () => 2000, - getSessionAffinity: () => false, - getSessionAffinityTtlMs: () => 1_200_000, - getSessionAffinityMaxEntries: () => 512, + getSessionAffinity: vi.fn(() => false), + getSessionAffinityTtlMs: vi.fn(() => 1_200_000), + getSessionAffinityMaxEntries: vi.fn(() => 512), + getResponseContinuation: vi.fn(() => false), getProactiveRefreshGuardian: () => false, getProactiveRefreshIntervalMs: () => 60000, getProactiveRefreshBufferMs: () => 300000, @@ -1315,6 +1316,121 @@ describe("OpenAIOAuthPlugin fetch handler", () => { expect(syncCodexCliSelectionMock).toHaveBeenCalledWith(0); }); + it("injects stored previous_response_id on follow-up requests when continuation is enabled", async () => { + const { AccountManager } = await import("../lib/accounts.js"); + const configModule = await import("../lib/config.js"); + const fetchHelpers = await import("../lib/request/fetch-helpers.js"); + + vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValueOnce( + buildRoutingManager([ + { + index: 0, + accountId: "acc-1", + email: "user@example.com", + refreshToken: "refresh-1", + }, + ]) as never, + ); + vi.mocked(configModule.getSessionAffinity).mockReturnValue(true); + vi.mocked(configModule.getResponseContinuation).mockReturnValue(true); + vi.mocked(fetchHelpers.transformRequestForCodex).mockImplementation( + async (init, _url, _userConfig, _codexMode, body) => ({ + updatedInit: { + ...(init as RequestInit), + body: JSON.stringify(body ?? {}), + }, + body: (body ?? { model: "gpt-5.4" }) as { model: string }, + }), + ); + vi.mocked(fetchHelpers.handleSuccessResponse) + .mockImplementationOnce(async (response, _isStreaming, options) => { + options?.onResponseId?.("resp_prev_123"); + return response; + }) + .mockImplementationOnce(async (response) => response); + + globalThis.fetch = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ content: "ok" }), { status: 200 })); + + const { sdk } = await setupPlugin(); + await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.4", prompt_cache_key: "ses_contract" }), + }); + await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.4", prompt_cache_key: "ses_contract" }), + }); + + const firstBody = vi.mocked(fetchHelpers.transformRequestForCodex).mock.calls[0]?.[4] as + | { previous_response_id?: string } + | undefined; + const secondBody = vi.mocked(fetchHelpers.transformRequestForCodex).mock.calls[1]?.[4] as + | { previous_response_id?: string } + | undefined; + + expect(firstBody?.previous_response_id).toBeUndefined(); + expect(secondBody?.previous_response_id).toBe("resp_prev_123"); + }); + + it("preserves explicit previous_response_id over stored continuation state", async () => { + const { AccountManager } = await import("../lib/accounts.js"); + const configModule = await import("../lib/config.js"); + const fetchHelpers = await import("../lib/request/fetch-helpers.js"); + + vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValueOnce( + buildRoutingManager([ + { + index: 0, + accountId: "acc-1", + email: "user@example.com", + refreshToken: "refresh-1", + }, + ]) as never, + ); + vi.mocked(configModule.getSessionAffinity).mockReturnValue(true); + vi.mocked(configModule.getResponseContinuation).mockReturnValue(true); + vi.mocked(fetchHelpers.transformRequestForCodex).mockImplementation( + async (init, _url, _userConfig, _codexMode, body) => ({ + updatedInit: { + ...(init as RequestInit), + body: JSON.stringify(body ?? {}), + }, + body: (body ?? { model: "gpt-5.4" }) as { model: string }, + }), + ); + vi.mocked(fetchHelpers.handleSuccessResponse) + .mockImplementationOnce(async (response, _isStreaming, options) => { + options?.onResponseId?.("resp_prev_123"); + return response; + }) + .mockImplementationOnce(async (response) => response); + + globalThis.fetch = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ content: "ok" }), { status: 200 })); + + const { sdk } = await setupPlugin(); + await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.4", prompt_cache_key: "ses_contract" }), + }); + await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ + model: "gpt-5.4", + prompt_cache_key: "ses_contract", + previous_response_id: "resp_explicit_456", + }), + }); + + const secondBody = vi.mocked(fetchHelpers.transformRequestForCodex).mock.calls[1]?.[4] as + | { previous_response_id?: string } + | undefined; + expect(secondBody?.previous_response_id).toBe("resp_explicit_456"); + }); + it("uses the refreshed token email when checking entitlement blocks", async () => { const { AccountManager } = await import("../lib/accounts.js"); const manager = buildRoutingManager([ diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index 9caebf96..ed3bb8fa 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -21,6 +21,7 @@ import { getPreemptiveQuotaRemainingPercent5h, getPreemptiveQuotaRemainingPercent7d, getPreemptiveQuotaMaxDeferralMs, + getResponseContinuation, } from '../lib/config.js'; import type { PluginConfig } from '../lib/types.js'; import * as fs from 'node:fs'; @@ -63,6 +64,7 @@ describe('Plugin Configuration', () => { 'CODEX_AUTH_UNSUPPORTED_MODEL_POLICY', 'CODEX_AUTH_FALLBACK_UNSUPPORTED_MODEL', 'CODEX_AUTH_FALLBACK_GPT53_TO_GPT52', + 'CODEX_AUTH_RESPONSE_CONTINUATION', 'CODEX_AUTH_PREEMPTIVE_QUOTA_ENABLED', 'CODEX_AUTH_PREEMPTIVE_QUOTA_5H_REMAINING_PCT', 'CODEX_AUTH_PREEMPTIVE_QUOTA_7D_REMAINING_PCT', @@ -129,6 +131,7 @@ describe('Plugin Configuration', () => { sessionAffinity: true, sessionAffinityTtlMs: 20 * 60_000, sessionAffinityMaxEntries: 512, + responseContinuation: false, proactiveRefreshGuardian: true, proactiveRefreshIntervalMs: 60_000, proactiveRefreshBufferMs: 5 * 60_000, @@ -187,6 +190,7 @@ describe('Plugin Configuration', () => { sessionAffinity: true, sessionAffinityTtlMs: 20 * 60_000, sessionAffinityMaxEntries: 512, + responseContinuation: false, proactiveRefreshGuardian: true, proactiveRefreshIntervalMs: 60_000, proactiveRefreshBufferMs: 5 * 60_000, @@ -442,6 +446,7 @@ describe('Plugin Configuration', () => { sessionAffinity: true, sessionAffinityTtlMs: 20 * 60_000, sessionAffinityMaxEntries: 512, + responseContinuation: false, proactiveRefreshGuardian: true, proactiveRefreshIntervalMs: 60_000, proactiveRefreshBufferMs: 5 * 60_000, @@ -506,6 +511,7 @@ describe('Plugin Configuration', () => { sessionAffinity: true, sessionAffinityTtlMs: 20 * 60_000, sessionAffinityMaxEntries: 512, + responseContinuation: false, proactiveRefreshGuardian: true, proactiveRefreshIntervalMs: 60_000, proactiveRefreshBufferMs: 5 * 60_000, @@ -564,6 +570,7 @@ describe('Plugin Configuration', () => { sessionAffinity: true, sessionAffinityTtlMs: 20 * 60_000, sessionAffinityMaxEntries: 512, + responseContinuation: false, proactiveRefreshGuardian: true, proactiveRefreshIntervalMs: 60_000, proactiveRefreshBufferMs: 5 * 60_000, @@ -657,6 +664,25 @@ describe('Plugin Configuration', () => { }); }); + describe('getResponseContinuation', () => { + it('should default to false', () => { + delete process.env.CODEX_AUTH_RESPONSE_CONTINUATION; + expect(getResponseContinuation({})).toBe(false); + }); + + it('should use config value when env var not set', () => { + delete process.env.CODEX_AUTH_RESPONSE_CONTINUATION; + expect(getResponseContinuation({ responseContinuation: true })).toBe(true); + }); + + it('should prioritize env override', () => { + process.env.CODEX_AUTH_RESPONSE_CONTINUATION = '1'; + expect(getResponseContinuation({ responseContinuation: false })).toBe(true); + process.env.CODEX_AUTH_RESPONSE_CONTINUATION = '0'; + expect(getResponseContinuation({ responseContinuation: true })).toBe(false); + }); + }); + describe('getCodexTuiV2', () => { it('should default to true', () => { delete process.env.CODEX_TUI_V2; diff --git a/test/request-transformer.test.ts b/test/request-transformer.test.ts index 0dd01959..51eb1214 100644 --- a/test/request-transformer.test.ts +++ b/test/request-transformer.test.ts @@ -612,6 +612,52 @@ describe('Request Transformer Module', () => { expect(result.prompt_cache_key).toBeUndefined(); }); + it('preserves host-provided previous_response_id', async () => { + const body: RequestBody = { + model: 'gpt-5.4', + input: [], + previous_response_id: 'resp_prior_123', + }; + const result = await transformRequestBody(body, codexInstructions); + expect(result.previous_response_id).toBe('resp_prior_123'); + }); + + it('preserves prompt_cache_retention settings', async () => { + const body: RequestBody = { + model: 'gpt-5.4', + input: [], + prompt_cache_key: 'ses_cache_key_123', + prompt_cache_retention: '24h', + }; + const result = await transformRequestBody(body, codexInstructions); + expect(result.prompt_cache_key).toBe('ses_cache_key_123'); + expect(result.prompt_cache_retention).toBe('24h'); + }); + + it('preserves text.format when applying text verbosity defaults', async () => { + const body: RequestBody = { + model: 'gpt-5.4', + input: [], + text: { + format: { + type: 'json_schema', + name: 'contract_response', + schema: { + type: 'object', + properties: { + answer: { type: 'string' }, + }, + required: ['answer'], + }, + strict: true, + }, + }, + }; + const result = await transformRequestBody(body, codexInstructions); + expect(result.text?.verbosity).toBe('medium'); + expect(result.text?.format).toEqual(body.text?.format); + }); + it('should set required Codex fields', async () => { const body: RequestBody = { model: 'gpt-5', diff --git a/test/response-handler.test.ts b/test/response-handler.test.ts index 2da04e9d..880554c6 100644 --- a/test/response-handler.test.ts +++ b/test/response-handler.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, vi } from 'vitest'; -import { ensureContentType, convertSseToJson, isEmptyResponse } from '../lib/request/response-handler.js'; +import { + attachResponseIdCapture, + ensureContentType, + convertSseToJson, + isEmptyResponse, +} from '../lib/request/response-handler.js'; describe('Response Handler Module', () => { describe('ensureContentType', () => { @@ -111,6 +116,19 @@ data: {"type":"response.done","response":{"id":"resp_789"}} expect(result.statusText).toBe('OK'); }); + it('should report the final response id while converting SSE to JSON', async () => { + const onResponseId = vi.fn(); + const sseContent = `data: {"type":"response.done","response":{"id":"resp_123","output":"test"}}`; + const response = new Response(sseContent); + const headers = new Headers(); + + const result = await convertSseToJson(response, headers, { onResponseId }); + const body = await result.json(); + + expect(body).toEqual({ id: 'resp_123', output: 'test' }); + expect(onResponseId).toHaveBeenCalledWith('resp_123'); + }); + it('should throw error if SSE stream exceeds size limit', async () => { const largeContent = 'a'.repeat(20 * 1024 * 1024 + 1); const response = new Response(largeContent); @@ -165,6 +183,27 @@ data: {"type":"response.done","response":{"id":"resp_789"}} }); }); + describe('attachResponseIdCapture', () => { + it('should capture response ids while preserving the SSE stream', async () => { + const onResponseId = vi.fn(); + const sseContent = [ + 'data: {"type":"response.started"}', + '', + 'data: {"type":"response.done","response":{"id":"resp_stream_123","output":"done"}}', + '', + ].join('\n'); + const response = new Response(sseContent); + const headers = new Headers({ 'content-type': 'text/event-stream' }); + + const captured = attachResponseIdCapture(response, headers, onResponseId); + const text = await captured.text(); + + expect(text).toBe(sseContent); + expect(onResponseId).toHaveBeenCalledWith('resp_stream_123'); + expect(captured.headers.get('content-type')).toBe('text/event-stream'); + }); + }); + describe('isEmptyResponse', () => { it('should return true for null', () => { expect(isEmptyResponse(null)).toBe(true); diff --git a/test/session-affinity.test.ts b/test/session-affinity.test.ts index a8268e20..5fa57b51 100644 --- a/test/session-affinity.test.ts +++ b/test/session-affinity.test.ts @@ -113,4 +113,24 @@ describe("SessionAffinityStore", () => { expect(store.getPreferredAccountIndex("s1", 2_001)).toBeNull(); expect(store.getPreferredAccountIndex("s2", 2_001)).toBe(1); }); + + it("stores and retrieves the last response id for a live session", () => { + const store = new SessionAffinityStore({ ttlMs: 10_000, maxEntries: 4 }); + store.remember("session-a", 1, 1_000); + store.rememberLastResponseId("session-a", "resp_123", 2_000); + + expect(store.getLastResponseId("session-a", 2_500)).toBe("resp_123"); + expect(store.getPreferredAccountIndex("session-a", 2_500)).toBe(1); + }); + + it("does not persist response ids for missing or expired sessions", () => { + const store = new SessionAffinityStore({ ttlMs: 1_000, maxEntries: 4 }); + store.rememberLastResponseId("missing", "resp_missing", 1_000); + expect(store.getLastResponseId("missing", 1_500)).toBeNull(); + + store.remember("session-a", 1, 1_000); + store.rememberLastResponseId("session-a", "resp_123", 2_500); + expect(store.getLastResponseId("session-a", 2_500)).toBeNull(); + expect(store.size()).toBe(0); + }); }); From 9968ba8c5948383a313d9f43b8bcdf0c552d8564 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 16:02:34 +0800 Subject: [PATCH 309/376] enhance responses parser for semantic SSE events --- lib/request/response-handler.ts | 426 +++++++++++++++++++++++++++++++- test/fetch-helpers.test.ts | 19 ++ test/response-handler.test.ts | 82 ++++++ 3 files changed, 516 insertions(+), 11 deletions(-) diff --git a/lib/request/response-handler.ts b/lib/request/response-handler.ts index b8db279b..a36f89ae 100644 --- a/lib/request/response-handler.ts +++ b/lib/request/response-handler.ts @@ -1,5 +1,6 @@ import { createLogger, logRequest, LOGGING_ENABLED } from "../logger.js"; import { PLUGIN_NAME } from "../constants.js"; +import { isRecord } from "../utils.js"; import type { SSEEventData } from "../types.js"; @@ -8,6 +9,322 @@ const log = createLogger("response-handler"); const MAX_SSE_SIZE = 10 * 1024 * 1024; // 10MB limit to prevent memory exhaustion const DEFAULT_STREAM_STALL_TIMEOUT_MS = 45_000; +type MutableRecord = Record; + +interface ParsedResponseState { + finalResponse: MutableRecord | null; + lastPhase: string | null; + outputItems: Map; + outputText: Map; + phaseText: Map; + reasoningSummaryText: Map; +} + +function createParsedResponseState(): ParsedResponseState { + return { + finalResponse: null, + lastPhase: null, + outputItems: new Map(), + outputText: new Map(), + phaseText: new Map(), + reasoningSummaryText: new Map(), + }; +} + +function toMutableRecord(value: unknown): MutableRecord | null { + return isRecord(value) ? { ...value } : null; +} + +function getNumberField(record: MutableRecord, key: string): number | null { + const value = record[key]; + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function getStringField(record: MutableRecord, key: string): string | null { + const value = record[key]; + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +function cloneContentArray(content: unknown): MutableRecord[] { + if (!Array.isArray(content)) return []; + return content.filter(isRecord).map((part) => ({ ...part })); +} + +function mergeRecord(base: MutableRecord | null, update: MutableRecord): MutableRecord { + if (!base) return { ...update }; + const merged: MutableRecord = { ...base, ...update }; + if ("content" in update || "content" in base) { + merged.content = cloneContentArray(update.content ?? base.content); + } + return merged; +} + +function makeOutputTextKey(outputIndex: number | null, contentIndex: number | null): string | null { + if (outputIndex === null || contentIndex === null) return null; + return `${outputIndex}:${contentIndex}`; +} + +function makeSummaryKey(outputIndex: number | null, summaryIndex: number | null): string | null { + if (outputIndex === null || summaryIndex === null) return null; + return `${outputIndex}:${summaryIndex}`; +} + +function getPartText(part: unknown): string | null { + if (!isRecord(part)) return null; + const text = getStringField(part, "text"); + if (text) return text; + return null; +} + +function capturePhase( + state: ParsedResponseState, + phase: unknown, + text: string | null = null, +): void { + if (typeof phase !== "string" || phase.trim().length === 0) return; + const normalizedPhase = phase.trim(); + state.lastPhase = normalizedPhase; + if (text && text.length > 0) { + const existing = state.phaseText.get(normalizedPhase) ?? ""; + state.phaseText.set(normalizedPhase, `${existing}${text}`); + } +} + +function upsertOutputItem(state: ParsedResponseState, outputIndex: number | null, item: unknown): void { + if (outputIndex === null || !isRecord(item)) return; + const current = state.outputItems.get(outputIndex) ?? null; + const merged = mergeRecord(current, item); + state.outputItems.set(outputIndex, merged); + capturePhase(state, merged.phase); +} + +function setOutputTextValue( + state: ParsedResponseState, + outputIndex: number | null, + contentIndex: number | null, + text: string | null, + phase: unknown = undefined, +): void { + if (!text) return; + const key = makeOutputTextKey(outputIndex, contentIndex); + if (!key) return; + const existing = state.outputText.get(key) ?? ""; + state.outputText.set(key, text); + const phaseDelta = existing.length > 0 && text.startsWith(existing) + ? text.slice(existing.length) + : existing === text + ? "" + : text; + capturePhase(state, phase, phaseDelta); +} + +function appendOutputTextValue( + state: ParsedResponseState, + outputIndex: number | null, + contentIndex: number | null, + delta: string | null, + phase: unknown = undefined, +): void { + if (!delta) return; + const key = makeOutputTextKey(outputIndex, contentIndex); + if (!key) return; + const existing = state.outputText.get(key) ?? ""; + state.outputText.set(key, `${existing}${delta}`); + capturePhase(state, phase, delta); +} + +function setReasoningSummaryValue( + state: ParsedResponseState, + outputIndex: number | null, + summaryIndex: number | null, + text: string | null, +): void { + if (!text) return; + const key = makeSummaryKey(outputIndex, summaryIndex); + if (!key) return; + state.reasoningSummaryText.set(key, text); +} + +function appendReasoningSummaryValue( + state: ParsedResponseState, + outputIndex: number | null, + summaryIndex: number | null, + delta: string | null, +): void { + if (!delta) return; + const key = makeSummaryKey(outputIndex, summaryIndex); + if (!key) return; + const existing = state.reasoningSummaryText.get(key) ?? ""; + state.reasoningSummaryText.set(key, `${existing}${delta}`); +} + +function ensureOutputItemAtIndex(output: unknown[], index: number): MutableRecord | null { + while (output.length <= index) { + output.push({}); + } + const current = output[index]; + if (!isRecord(current)) { + output[index] = {}; + } + return isRecord(output[index]) ? (output[index] as MutableRecord) : null; +} + +function ensureContentPartAtIndex(item: MutableRecord, index: number): MutableRecord | null { + const content = Array.isArray(item.content) ? [...item.content] : []; + while (content.length <= index) { + content.push({}); + } + const current = content[index]; + if (!isRecord(current)) { + content[index] = {}; + } + item.content = content; + return isRecord(content[index]) ? (content[index] as MutableRecord) : null; +} + +function applyAccumulatedOutputText(response: MutableRecord, state: ParsedResponseState): void { + if (state.outputText.size === 0) return; + const output = Array.isArray(response.output) ? [...response.output] : []; + + for (const [key, text] of state.outputText.entries()) { + const [outputIndexText, contentIndexText] = key.split(":"); + const outputIndex = Number.parseInt(outputIndexText ?? "", 10); + const contentIndex = Number.parseInt(contentIndexText ?? "", 10); + if (!Number.isFinite(outputIndex) || !Number.isFinite(contentIndex)) continue; + const item = ensureOutputItemAtIndex(output, outputIndex); + if (!item) continue; + const part = ensureContentPartAtIndex(item, contentIndex); + if (!part) continue; + if (!getStringField(part, "type")) { + part.type = "output_text"; + } + part.text = text; + } + + if (output.length > 0) { + response.output = output; + } +} + +function mergeOutputItemsIntoResponse(response: MutableRecord, state: ParsedResponseState): void { + if (state.outputItems.size === 0) return; + const output = Array.isArray(response.output) ? [...response.output] : []; + + for (const [outputIndex, item] of state.outputItems.entries()) { + while (output.length <= outputIndex) { + output.push({}); + } + output[outputIndex] = mergeRecord(toMutableRecord(output[outputIndex]), item); + } + + response.output = output; +} + +function collectMessageOutputText(output: unknown[]): string { + return output + .filter(isRecord) + .map((item) => { + if (item.type !== "message") return ""; + const content = Array.isArray(item.content) ? item.content : []; + return content + .filter(isRecord) + .map((part) => { + if (part.type !== "output_text") return ""; + return typeof part.text === "string" ? part.text : ""; + }) + .join(""); + }) + .filter((text) => text.length > 0) + .join(""); +} + +function collectReasoningSummaryText(output: unknown[]): string { + return output + .filter(isRecord) + .map((item) => { + if (item.type !== "reasoning") return ""; + const summary = Array.isArray(item.summary) ? item.summary : []; + return summary + .filter(isRecord) + .map((part) => (typeof part.text === "string" ? part.text : "")) + .filter((text) => text.length > 0) + .join("\n\n"); + }) + .filter((text) => text.length > 0) + .join("\n\n"); +} + +function applyReasoningSummaries(response: MutableRecord, state: ParsedResponseState): void { + if (state.reasoningSummaryText.size === 0) return; + const output = Array.isArray(response.output) ? [...response.output] : []; + + for (const [key, text] of state.reasoningSummaryText.entries()) { + const [outputIndexText, summaryIndexText] = key.split(":"); + const outputIndex = Number.parseInt(outputIndexText ?? "", 10); + const summaryIndex = Number.parseInt(summaryIndexText ?? "", 10); + if (!Number.isFinite(outputIndex) || !Number.isFinite(summaryIndex)) continue; + const item = ensureOutputItemAtIndex(output, outputIndex); + if (!item) continue; + const summary = Array.isArray(item.summary) ? [...item.summary] : []; + while (summary.length <= summaryIndex) { + summary.push({}); + } + const current = summary[summaryIndex]; + const nextPart = isRecord(current) ? { ...current } : {}; + if (!getStringField(nextPart, "type")) { + nextPart.type = "summary_text"; + } + nextPart.text = text; + summary[summaryIndex] = nextPart; + item.summary = summary; + if (!getStringField(item, "type")) { + item.type = "reasoning"; + } + } + + if (output.length > 0) { + response.output = output; + } +} + +function finalizeParsedResponse(state: ParsedResponseState): MutableRecord | null { + const response = state.finalResponse ? { ...state.finalResponse } : null; + if (!response) return null; + + mergeOutputItemsIntoResponse(response, state); + applyAccumulatedOutputText(response, state); + applyReasoningSummaries(response, state); + + const output = Array.isArray(response.output) ? response.output : []; + if (typeof response.output_text !== "string") { + const outputText = collectMessageOutputText(output); + if (outputText.length > 0) { + response.output_text = outputText; + } + } + + const reasoningSummaryText = collectReasoningSummaryText(output); + if (reasoningSummaryText.length > 0) { + response.reasoning_summary_text = reasoningSummaryText; + } + + if (state.lastPhase && typeof response.phase !== "string") { + response.phase = state.lastPhase; + } + + if (state.phaseText.size > 0) { + const phaseText: MutableRecord = {}; + for (const [phase, text] of state.phaseText.entries()) { + phaseText[phase] = text; + if (phase === "commentary") response.commentary_text = text; + if (phase === "final_answer") response.final_answer_text = text; + } + response.phase_text = phaseText; + } + + return response; +} + function extractResponseId(response: unknown): string | null { if (!response || typeof response !== "object") return null; const candidate = (response as { id?: unknown }).id; @@ -33,20 +350,105 @@ function notifyResponseId( } function maybeCaptureResponseEvent( + state: ParsedResponseState, data: SSEEventData, onResponseId?: (responseId: string) => void, -): unknown | null { +): void { if (data.type === "error") { log.error("SSE error event received", { error: data }); - return null; + return; } - if (data.type === "response.done" || data.type === "response.completed") { + if (isRecord(data.response)) { + state.finalResponse = { ...data.response }; notifyResponseId(onResponseId, data.response); - return data.response ?? null; } - return null; + if (data.type === "response.done" || data.type === "response.completed") { + return; + } + + const eventRecord = toMutableRecord(data); + if (!eventRecord) return; + const outputIndex = getNumberField(eventRecord, "output_index"); + + if (data.type === "response.output_item.added" || data.type === "response.output_item.done") { + upsertOutputItem(state, outputIndex, eventRecord.item); + return; + } + + if (data.type === "response.output_text.delta") { + appendOutputTextValue( + state, + outputIndex, + getNumberField(eventRecord, "content_index"), + getStringField(eventRecord, "delta"), + eventRecord.phase, + ); + return; + } + + if (data.type === "response.output_text.done") { + setOutputTextValue( + state, + outputIndex, + getNumberField(eventRecord, "content_index"), + getStringField(eventRecord, "text"), + eventRecord.phase, + ); + return; + } + + if (data.type === "response.content_part.added" || data.type === "response.content_part.done") { + const part = toMutableRecord(eventRecord.part); + if (!part || getStringField(part, "type") !== "output_text") { + capturePhase(state, part?.phase); + return; + } + setOutputTextValue( + state, + outputIndex, + getNumberField(eventRecord, "content_index"), + getPartText(part), + part.phase, + ); + return; + } + + if (data.type === "response.reasoning_summary_text.delta") { + appendReasoningSummaryValue( + state, + outputIndex, + getNumberField(eventRecord, "summary_index"), + getStringField(eventRecord, "delta"), + ); + return; + } + + if (data.type === "response.reasoning_summary_text.done") { + setReasoningSummaryValue( + state, + outputIndex, + getNumberField(eventRecord, "summary_index"), + getStringField(eventRecord, "text"), + ); + return; + } + + if ( + data.type === "response.reasoning_summary_part.added" || + data.type === "response.reasoning_summary_part.done" + ) { + setReasoningSummaryValue( + state, + outputIndex, + getNumberField(eventRecord, "summary_index"), + getPartText(eventRecord.part), + ); + return; + } + + capturePhase(state, eventRecord.phase); } /** @@ -60,6 +462,7 @@ function parseSseStream( onResponseId?: (responseId: string) => void, ): unknown | null { const lines = sseText.split(/\r?\n/); + const state = createParsedResponseState(); for (const line of lines) { const trimmedLine = line.trim(); @@ -68,15 +471,14 @@ function parseSseStream( if (!payload || payload === '[DONE]') continue; try { const data = JSON.parse(payload) as SSEEventData; - const finalResponse = maybeCaptureResponseEvent(data, onResponseId); - if (finalResponse) return finalResponse; + maybeCaptureResponseEvent(state, data, onResponseId); } catch { // Skip malformed JSON } } } - return null; + return finalizeParsedResponse(state); } /** @@ -125,7 +527,9 @@ export async function convertSseToJson( if (!finalResponse) { log.warn("Could not find final response in SSE stream"); - logRequest("stream-error", { error: "No response.done event found" }); + logRequest("stream-error", { + error: "No terminal response event found in SSE stream", + }); // Return original stream if we can't parse return new Response(fullText, { @@ -181,7 +585,7 @@ function createResponseIdCapturingStream( if (!payload || payload === "[DONE]") continue; try { const data = JSON.parse(payload) as SSEEventData; - maybeCaptureResponseEvent(data, onResponseId); + maybeCaptureResponseEvent(createParsedResponseState(), data, onResponseId); } catch { // Ignore malformed SSE lines and keep forwarding the raw stream. } @@ -230,7 +634,7 @@ async function readWithTimeout( timeoutId = setTimeout(() => { reject( new Error( - `SSE stream stalled for ${timeoutMs}ms while waiting for response.done`, + `SSE stream stalled for ${timeoutMs}ms while waiting for a terminal response event`, ), ); }, timeoutMs); diff --git a/test/fetch-helpers.test.ts b/test/fetch-helpers.test.ts index 518a725c..f90108e7 100644 --- a/test/fetch-helpers.test.ts +++ b/test/fetch-helpers.test.ts @@ -743,6 +743,25 @@ describe('createEntitlementErrorResponse', () => { const text = await result.text(); expect(text).toBe('stream body'); }); + + it('captures response ids from streaming semantic SSE without rewriting the stream', async () => { + const onResponseId = vi.fn(); + const response = new Response( + [ + 'data: {"type":"response.created","response":{"id":"resp_stream_123"}}', + '', + 'data: {"type":"response.done","response":{"id":"resp_stream_123"}}', + '', + ].join('\n'), + { status: 200, headers: new Headers({ 'content-type': 'text/event-stream' }) }, + ); + + const result = await handleSuccessResponse(response, true, { onResponseId }); + const text = await result.text(); + + expect(text).toContain('"resp_stream_123"'); + expect(onResponseId).toHaveBeenCalledWith('resp_stream_123'); + }); }); describe('handleErrorResponse error normalization', () => { diff --git a/test/response-handler.test.ts b/test/response-handler.test.ts index 880554c6..2fbc7538 100644 --- a/test/response-handler.test.ts +++ b/test/response-handler.test.ts @@ -66,6 +66,88 @@ data: {"type":"response.completed","response":{"id":"resp_456","output":"done"}} expect(body).toEqual({ id: 'resp_456', output: 'done' }); }); + it('synthesizes output_text and reasoning summaries from semantic SSE events', async () => { + const sseContent = [ + 'data: {"type":"response.created","response":{"id":"resp_semantic_123","object":"response"}}', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_123","type":"message","role":"assistant","phase":"final_answer"}}', + 'data: {"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"Hello ","phase":"final_answer"}', + 'data: {"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"world","phase":"final_answer"}', + 'data: {"type":"response.output_text.done","output_index":0,"content_index":0,"text":"Hello world","phase":"final_answer"}', + 'data: {"type":"response.output_item.added","output_index":1,"item":{"id":"rs_123","type":"reasoning"}}', + 'data: {"type":"response.reasoning_summary_text.delta","output_index":1,"summary_index":0,"delta":"Need more context."}', + 'data: {"type":"response.reasoning_summary_text.done","output_index":1,"summary_index":0,"text":"Need more context."}', + 'data: {"type":"response.completed","response":{"id":"resp_semantic_123","object":"response"}}', + '', + ].join('\n'); + const response = new Response(sseContent); + const headers = new Headers(); + + const result = await convertSseToJson(response, headers); + const body = await result.json() as { + id: string; + output?: Array<{ + type?: string; + role?: string; + phase?: string; + content?: Array<{ type?: string; text?: string }>; + summary?: Array<{ type?: string; text?: string }>; + }>; + output_text?: string; + reasoning_summary_text?: string; + phase?: string; + final_answer_text?: string; + phase_text?: Record; + }; + + expect(body.id).toBe('resp_semantic_123'); + expect(body.output_text).toBe('Hello world'); + expect(body.reasoning_summary_text).toBe('Need more context.'); + expect(body.phase).toBe('final_answer'); + expect(body.final_answer_text).toBe('Hello world'); + expect(body.phase_text).toEqual({ final_answer: 'Hello world' }); + expect(body.output?.[0]?.content?.[0]).toEqual({ + type: 'output_text', + text: 'Hello world', + }); + expect(body.output?.[1]?.summary?.[0]).toEqual({ + type: 'summary_text', + text: 'Need more context.', + }); + }); + + it('tracks commentary and final_answer phase text separately when phase labels are present', async () => { + const sseContent = [ + 'data: {"type":"response.created","response":{"id":"resp_phase_123","object":"response"}}', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_123","type":"message","role":"assistant","phase":"commentary"}}', + 'data: {"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"Thinking...","phase":"commentary"}', + 'data: {"type":"response.output_text.done","output_index":0,"content_index":0,"text":"Thinking...","phase":"commentary"}', + 'data: {"type":"response.output_item.done","output_index":0,"item":{"id":"msg_123","type":"message","role":"assistant","phase":"final_answer"}}', + 'data: {"type":"response.output_text.done","output_index":0,"content_index":1,"text":"Done.","phase":"final_answer"}', + 'data: {"type":"response.done","response":{"id":"resp_phase_123","object":"response"}}', + '', + ].join('\n'); + const response = new Response(sseContent); + const headers = new Headers(); + + const result = await convertSseToJson(response, headers); + const body = await result.json() as { + phase?: string; + commentary_text?: string; + final_answer_text?: string; + phase_text?: Record; + output_text?: string; + }; + + expect(body.phase).toBe('final_answer'); + expect(body.commentary_text).toBe('Thinking...'); + expect(body.final_answer_text).toBe('Done.'); + expect(body.phase_text).toEqual({ + commentary: 'Thinking...', + final_answer: 'Done.', + }); + expect(body.output_text).toBe('Thinking...Done.'); + }); + it('should return original text if no final response found', async () => { const sseContent = `data: {"type":"response.started"} data: {"type":"chunk","delta":"text"} From 71d44c73974f2e91d6647d5ef86339d998deb6f8 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 16:28:19 +0800 Subject: [PATCH 310/376] add response compaction fallback for fast sessions --- index.ts | 34 +++++++ lib/request/fetch-helpers.ts | 27 ++++- lib/request/helpers/model-map.ts | 20 +++- lib/request/request-transformer.ts | 64 ++++++++++-- lib/request/response-compaction.ts | 158 +++++++++++++++++++++++++++++ test/codex-manager-cli.test.ts | 10 +- test/index.test.ts | 63 +++++++++++- test/model-map.test.ts | 8 ++ test/request-transformer.test.ts | 28 ++++- test/response-compaction.test.ts | 115 +++++++++++++++++++++ 10 files changed, 502 insertions(+), 25 deletions(-) create mode 100644 lib/request/response-compaction.ts create mode 100644 test/response-compaction.test.ts diff --git a/index.ts b/index.ts index 98d56bdd..ec31a07b 100644 --- a/index.ts +++ b/index.ts @@ -154,6 +154,7 @@ import { isWorkspaceDisabledError, } from "./lib/request/fetch-helpers.js"; import { applyFastSessionDefaults } from "./lib/request/request-transformer.js"; +import { applyResponseCompaction } from "./lib/request/response-compaction.js"; import { getRateLimitBackoff, RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS, @@ -1369,10 +1370,13 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { fastSession: fastSessionEnabled, fastSessionStrategy, fastSessionMaxInputItems, + deferFastSessionInputTrimming: fastSessionEnabled, }, ); let requestInit = transformation?.updatedInit ?? baseInit; let transformedBody: RequestBody | undefined = transformation?.body; + const deferredFastSessionInputTrim = + transformation?.deferredFastSessionInputTrim; const promptCacheKey = transformedBody?.prompt_cache_key; let model = transformedBody?.model; let modelFamily = model ? getModelFamily(model) : "gpt-5.1"; @@ -1670,6 +1674,36 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { promptCacheKey: effectivePromptCacheKey, }, ); + if (transformedBody && deferredFastSessionInputTrim) { + const compactionResult = await applyResponseCompaction({ + body: transformedBody, + requestUrl: url, + headers, + trim: deferredFastSessionInputTrim, + fetchImpl: async (requestUrl, requestInit) => { + const normalizedCompactionUrl = + typeof requestUrl === "string" + ? requestUrl + : String(requestUrl); + return fetch( + normalizedCompactionUrl, + applyProxyCompatibleInit( + normalizedCompactionUrl, + requestInit, + ), + ); + }, + signal: abortSignal, + timeoutMs: Math.min(fetchTimeoutMs, 4_000), + }); + if (compactionResult.mode !== "unchanged") { + transformedBody = compactionResult.body; + requestInit = { + ...(requestInit ?? {}), + body: JSON.stringify(transformedBody), + }; + } + } const quotaScheduleKey = `${entitlementAccountKey}:${model ?? modelFamily}`; const capabilityModelKey = model ?? modelFamily; const quotaDeferral = preemptiveQuotaScheduler.getDeferral(quotaScheduleKey); diff --git a/lib/request/fetch-helpers.ts b/lib/request/fetch-helpers.ts index 37043418..348ea4f8 100644 --- a/lib/request/fetch-helpers.ts +++ b/lib/request/fetch-helpers.ts @@ -8,7 +8,12 @@ import { ProxyAgent } from "undici"; import { queuedRefresh } from "../refresh-queue.js"; import { logRequest, logError, logWarn } from "../logger.js"; import { getCodexInstructions, getModelFamily } from "../prompts/codex.js"; -import { transformRequestBody, normalizeModel } from "./request-transformer.js"; +import { + transformRequestBody, + normalizeModel, + resolveFastSessionInputTrimPlan, + type FastSessionInputTrimPlan, +} from "./request-transformer.js"; import { attachResponseIdCapture, convertSseToJson, @@ -99,6 +104,12 @@ export interface ResolveUnsupportedCodexFallbackOptions { customChain?: Record; } +export interface TransformRequestForCodexResult { + body: RequestBody; + updatedInit: RequestInit; + deferredFastSessionInputTrim?: FastSessionInputTrimPlan["trim"]; +} + function canonicalizeModelName(model: string | undefined): string | undefined { if (!model) return undefined; const trimmed = model.trim().toLowerCase(); @@ -651,8 +662,9 @@ export async function transformRequestForCodex( fastSession?: boolean; fastSessionStrategy?: "hybrid" | "always"; fastSessionMaxInputItems?: number; + deferFastSessionInputTrimming?: boolean; }, -): Promise<{ body: RequestBody; updatedInit: RequestInit } | undefined> { +): Promise { const hasParsedBody = parsedBody !== undefined && parsedBody !== null && @@ -670,6 +682,12 @@ export async function transformRequestForCodex( body = JSON.parse(init.body) as RequestBody; } const originalModel = body.model; + const fastSessionInputTrimPlan = resolveFastSessionInputTrimPlan( + body, + options?.fastSession ?? false, + options?.fastSessionStrategy ?? "hybrid", + options?.fastSessionMaxInputItems ?? 30, + ); // Normalize model first to determine which instructions to fetch // This ensures we get the correct model-specific prompt @@ -700,6 +718,7 @@ export async function transformRequestForCodex( options?.fastSession ?? false, options?.fastSessionStrategy ?? "hybrid", options?.fastSessionMaxInputItems ?? 30, + options?.deferFastSessionInputTrimming ?? false, ); // Log transformed request @@ -720,6 +739,10 @@ export async function transformRequestForCodex( return { body: transformedBody, updatedInit: { ...(init ?? {}), body: JSON.stringify(transformedBody) }, + deferredFastSessionInputTrim: + options?.deferFastSessionInputTrimming === true + ? fastSessionInputTrimPlan.trim + : undefined, }; } catch (e) { logError(`${ERROR_MESSAGES.REQUEST_PARSE_ERROR}`, e); diff --git a/lib/request/helpers/model-map.ts b/lib/request/helpers/model-map.ts index 20a6832d..b623c845 100644 --- a/lib/request/helpers/model-map.ts +++ b/lib/request/helpers/model-map.ts @@ -25,6 +25,7 @@ export type PromptModelFamily = export interface ModelCapabilities { toolSearch: boolean; computerUse: boolean; + compaction: boolean; } export interface ModelProfile { @@ -48,14 +49,27 @@ const TOOL_CAPABILITIES = { full: { toolSearch: true, computerUse: true, + compaction: true, }, computerOnly: { toolSearch: false, computerUse: true, + compaction: false, + }, + computerAndCompact: { + toolSearch: false, + computerUse: true, + compaction: true, + }, + compactOnly: { + toolSearch: false, + computerUse: false, + compaction: true, }, basic: { toolSearch: false, computerUse: false, + compaction: false, }, } as const satisfies Record; @@ -103,7 +117,7 @@ export const MODEL_PROFILES: Record = { promptFamily: "gpt-5.2", defaultReasoningEffort: "high", supportedReasoningEfforts: ["medium", "high", "xhigh"], - capabilities: TOOL_CAPABILITIES.computerOnly, + capabilities: TOOL_CAPABILITIES.computerAndCompact, }, "gpt-5.2-pro": { normalizedModel: "gpt-5.2-pro", @@ -145,14 +159,14 @@ export const MODEL_PROFILES: Record = { promptFamily: "gpt-5.2", defaultReasoningEffort: "medium", supportedReasoningEfforts: ["medium"], - capabilities: TOOL_CAPABILITIES.basic, + capabilities: TOOL_CAPABILITIES.full, }, "gpt-5-nano": { normalizedModel: "gpt-5-nano", promptFamily: "gpt-5.2", defaultReasoningEffort: "medium", supportedReasoningEfforts: ["medium"], - capabilities: TOOL_CAPABILITIES.basic, + capabilities: TOOL_CAPABILITIES.compactOnly, }, } as const; diff --git a/lib/request/request-transformer.ts b/lib/request/request-transformer.ts index 6c002476..3f6a3353 100644 --- a/lib/request/request-transformer.ts +++ b/lib/request/request-transformer.ts @@ -33,6 +33,7 @@ export interface TransformRequestBodyParams { fastSession?: boolean; fastSessionStrategy?: FastSessionStrategy; fastSessionMaxInputItems?: number; + deferFastSessionInputTrimming?: boolean; } const PLAN_MODE_ONLY_TOOLS = new Set(["request_user_input"]); @@ -482,6 +483,15 @@ export function trimInputForFastSession( return trimmed.slice(trimmed.length - safeMax); } +export interface FastSessionInputTrimPlan { + shouldApply: boolean; + isTrivialTurn: boolean; + trim?: { + maxItems: number; + preferLatestUserOnly: boolean; + }; +} + function isTrivialLatestPrompt(text: string): boolean { const normalized = text.trim(); if (!normalized) return false; @@ -540,6 +550,33 @@ function isComplexFastSessionRequest( return false; } +export function resolveFastSessionInputTrimPlan( + body: RequestBody, + fastSession: boolean, + fastSessionStrategy: FastSessionStrategy, + fastSessionMaxInputItems: number, +): FastSessionInputTrimPlan { + const shouldApplyFastSessionTuning = + fastSession && + (fastSessionStrategy === "always" || + !isComplexFastSessionRequest(body, fastSessionMaxInputItems)); + const latestUserText = getLatestUserText(body.input); + const isTrivialTurn = isTrivialLatestPrompt(latestUserText ?? ""); + const shouldPreferLatestUserOnly = + shouldApplyFastSessionTuning && isTrivialTurn; + + return { + shouldApply: shouldApplyFastSessionTuning, + isTrivialTurn, + trim: shouldApplyFastSessionTuning + ? { + maxItems: fastSessionMaxInputItems, + preferLatestUserOnly: shouldPreferLatestUserOnly, + } + : undefined, + }; +} + function getLatestUserText(input: InputItem[] | undefined): string | undefined { if (!Array.isArray(input)) return undefined; for (let i = input.length - 1; i >= 0; i--) { @@ -672,6 +709,7 @@ export async function transformRequestBody( fastSession?: boolean, fastSessionStrategy?: FastSessionStrategy, fastSessionMaxInputItems?: number, + deferFastSessionInputTrimming?: boolean, ): Promise; export async function transformRequestBody( bodyOrParams: RequestBody | TransformRequestBodyParams, @@ -681,6 +719,7 @@ export async function transformRequestBody( fastSession = false, fastSessionStrategy: FastSessionStrategy = "hybrid", fastSessionMaxInputItems = 30, + deferFastSessionInputTrimming = false, ): Promise { const useNamedParams = typeof codexInstructions === "undefined" && @@ -695,6 +734,7 @@ export async function transformRequestBody( let resolvedFastSession: boolean; let resolvedFastSessionStrategy: FastSessionStrategy; let resolvedFastSessionMaxInputItems: number; + let resolvedDeferFastSessionInputTrimming: boolean; if (useNamedParams) { const namedParams = bodyOrParams as TransformRequestBodyParams; @@ -705,6 +745,8 @@ export async function transformRequestBody( resolvedFastSession = namedParams.fastSession ?? false; resolvedFastSessionStrategy = namedParams.fastSessionStrategy ?? "hybrid"; resolvedFastSessionMaxInputItems = namedParams.fastSessionMaxInputItems ?? 30; + resolvedDeferFastSessionInputTrimming = + namedParams.deferFastSessionInputTrimming ?? false; } else { body = bodyOrParams as RequestBody; resolvedCodexInstructions = codexInstructions; @@ -713,6 +755,7 @@ export async function transformRequestBody( resolvedFastSession = fastSession; resolvedFastSessionStrategy = fastSessionStrategy; resolvedFastSessionMaxInputItems = fastSessionMaxInputItems; + resolvedDeferFastSessionInputTrimming = deferFastSessionInputTrimming; } if (!body || typeof body !== "object") { @@ -747,17 +790,17 @@ export async function transformRequestBody( const reasoningModel = shouldUseNormalizedReasoningModel ? normalizedModel : lookupModel; - const shouldApplyFastSessionTuning = - resolvedFastSession && - (resolvedFastSessionStrategy === "always" || - !isComplexFastSessionRequest(body, resolvedFastSessionMaxInputItems)); - const latestUserText = getLatestUserText(body.input); - const isTrivialTurn = isTrivialLatestPrompt(latestUserText ?? ""); + const fastSessionInputTrimPlan = resolveFastSessionInputTrimPlan( + body, + resolvedFastSession, + resolvedFastSessionStrategy, + resolvedFastSessionMaxInputItems, + ); + const shouldApplyFastSessionTuning = fastSessionInputTrimPlan.shouldApply; + const isTrivialTurn = fastSessionInputTrimPlan.isTrivialTurn; const shouldDisableToolsForTrivialTurn = shouldApplyFastSessionTuning && isTrivialTurn; - const shouldPreferLatestUserOnly = - shouldApplyFastSessionTuning && isTrivialTurn; // Codex required fields // ChatGPT backend REQUIRES store=false (confirmed via testing) @@ -789,10 +832,11 @@ export async function transformRequestBody( if (body.input && Array.isArray(body.input)) { let inputItems: InputItem[] = body.input; - if (shouldApplyFastSessionTuning) { + if (shouldApplyFastSessionTuning && !resolvedDeferFastSessionInputTrimming) { inputItems = trimInputForFastSession(inputItems, resolvedFastSessionMaxInputItems, { - preferLatestUserOnly: shouldPreferLatestUserOnly, + preferLatestUserOnly: + fastSessionInputTrimPlan.trim?.preferLatestUserOnly ?? false, }) ?? inputItems; } diff --git a/lib/request/response-compaction.ts b/lib/request/response-compaction.ts new file mode 100644 index 00000000..d61151fe --- /dev/null +++ b/lib/request/response-compaction.ts @@ -0,0 +1,158 @@ +import { logDebug, logWarn } from "../logger.js"; +import type { InputItem, RequestBody } from "../types.js"; +import { isRecord } from "../utils.js"; +import { getModelCapabilities } from "./helpers/model-map.js"; +import { trimInputForFastSession } from "./request-transformer.js"; + +export interface DeferredFastSessionInputTrim { + maxItems: number; + preferLatestUserOnly: boolean; +} + +export interface ResponseCompactionResult { + body: RequestBody; + mode: "compacted" | "trimmed" | "unchanged"; +} + +export interface ApplyResponseCompactionParams { + body: RequestBody; + requestUrl: string; + headers: Headers; + trim: DeferredFastSessionInputTrim; + fetchImpl: typeof fetch; + signal?: AbortSignal | null; + timeoutMs?: number; +} + +function isInputItemArray(value: unknown): value is InputItem[] { + return Array.isArray(value) && value.every((item) => isRecord(item)); +} + +function extractCompactedInput(payload: unknown): InputItem[] | undefined { + if (!isRecord(payload)) return undefined; + if (isInputItemArray(payload.output)) return payload.output; + if (isInputItemArray(payload.input)) return payload.input; + + const response = payload.response; + if (!isRecord(response)) return undefined; + if (isInputItemArray(response.output)) return response.output; + if (isInputItemArray(response.input)) return response.input; + return undefined; +} + +function buildCompactionUrl(requestUrl: string): string { + return requestUrl.endsWith("/compact") ? requestUrl : `${requestUrl}/compact`; +} + +function createFallbackBody( + body: RequestBody, + trim: DeferredFastSessionInputTrim, +): RequestBody | undefined { + if (!Array.isArray(body.input)) return undefined; + const trimmedInput = + trimInputForFastSession(body.input, trim.maxItems, { + preferLatestUserOnly: trim.preferLatestUserOnly, + }) ?? body.input; + + return trimmedInput === body.input ? undefined : { ...body, input: trimmedInput }; +} + +function createTimedAbortSignal( + signal: AbortSignal | null | undefined, + timeoutMs: number, +): { signal: AbortSignal; cleanup: () => void } { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(new Error("Response compaction timeout")); + }, timeoutMs); + + const onAbort = () => { + controller.abort(signal?.reason ?? new Error("Aborted")); + }; + + if (signal?.aborted) { + onAbort(); + } else if (signal) { + signal.addEventListener("abort", onAbort, { once: true }); + } + + return { + signal: controller.signal, + cleanup: () => { + clearTimeout(timeout); + signal?.removeEventListener("abort", onAbort); + }, + }; +} + +export async function applyResponseCompaction( + params: ApplyResponseCompactionParams, +): Promise { + const fallbackBody = createFallbackBody(params.body, params.trim); + if (!fallbackBody) { + return { body: params.body, mode: "unchanged" }; + } + + if (!getModelCapabilities(params.body.model).compaction) { + return { body: fallbackBody, mode: "trimmed" }; + } + + const compactionHeaders = new Headers(params.headers); + compactionHeaders.set("accept", "application/json"); + compactionHeaders.set("content-type", "application/json"); + const { signal, cleanup } = createTimedAbortSignal( + params.signal, + Math.max(250, params.timeoutMs ?? 4_000), + ); + + try { + const response = await params.fetchImpl(buildCompactionUrl(params.requestUrl), { + method: "POST", + headers: compactionHeaders, + body: JSON.stringify({ + model: params.body.model, + input: params.body.input, + }), + signal, + }); + + if (!response.ok) { + logWarn("Responses compaction request failed; using trim fallback.", { + status: response.status, + statusText: response.statusText, + model: params.body.model, + }); + return { body: fallbackBody, mode: "trimmed" }; + } + + const payload = (await response.json()) as unknown; + const compactedInput = extractCompactedInput(payload); + if (!compactedInput || compactedInput.length === 0) { + logWarn("Responses compaction returned no reusable input; using trim fallback.", { + model: params.body.model, + }); + return { body: fallbackBody, mode: "trimmed" }; + } + + logDebug("Applied server-side response compaction.", { + model: params.body.model, + originalInputLength: Array.isArray(params.body.input) ? params.body.input.length : 0, + compactedInputLength: compactedInput.length, + }); + return { body: { ...params.body, input: compactedInput }, mode: "compacted" }; + } catch (error) { + if (signal.aborted && params.signal?.aborted) { + throw params.signal.reason instanceof Error + ? params.signal.reason + : new Error("Aborted"); + } + + logWarn("Responses compaction failed; using trim fallback.", { + model: params.body.model, + error: error instanceof Error ? error.message : String(error), + }); + return { body: fallbackBody, mode: "trimmed" }; + } finally { + cleanup(); + } +} diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 613d6c93..ef1a6ead 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -5707,7 +5707,7 @@ describe("codex manager cli commands", () => { normalized: string; remapped: boolean; promptFamily: string; - capabilities: { toolSearch: boolean; computerUse: boolean }; + capabilities: { toolSearch: boolean; computerUse: boolean; compaction: boolean }; }; }; expect(payload.command).toBe("report"); @@ -5722,6 +5722,7 @@ describe("codex manager cli commands", () => { capabilities: { toolSearch: false, computerUse: false, + compaction: false, }, }); }); @@ -5760,7 +5761,7 @@ describe("codex manager cli commands", () => { normalized: string; remapped: boolean; promptFamily: string; - capabilities: { toolSearch: boolean; computerUse: boolean }; + capabilities: { toolSearch: boolean; computerUse: boolean; compaction: boolean }; }; }; expect(payload.modelSelection).toEqual({ @@ -5769,8 +5770,9 @@ describe("codex manager cli commands", () => { remapped: true, promptFamily: "gpt-5.2", capabilities: { - toolSearch: false, - computerUse: false, + toolSearch: true, + computerUse: true, + compaction: true, }, }); }); diff --git a/test/index.test.ts b/test/index.test.ts index fb89f4e6..7882bc6f 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -136,9 +136,13 @@ vi.mock("../lib/live-account-sync.js", () => ({ LiveAccountSync: liveAccountSyncCtorMock, })); -vi.mock("../lib/request/request-transformer.js", () => ({ - applyFastSessionDefaults: (config: T) => config, -})); +vi.mock("../lib/request/request-transformer.js", async () => { + const actual = await vi.importActual("../lib/request/request-transformer.js"); + return { + ...(actual as Record), + applyFastSessionDefaults: (config: T) => config, + }; +}); vi.mock("../lib/logger.js", () => ({ initLogger: vi.fn(), @@ -1431,6 +1435,59 @@ describe("OpenAIOAuthPlugin fetch handler", () => { expect(secondBody?.previous_response_id).toBe("resp_explicit_456"); }); + it("compacts fast-session input before sending the upstream request when compaction succeeds", async () => { + const fetchHelpers = await import("../lib/request/fetch-helpers.js"); + const longInput = Array.from({ length: 12 }, (_value, index) => ({ + type: "message", + role: index === 0 ? "developer" : "user", + content: index === 0 ? "system prompt" : `message-${index}`, + })); + const compactedInput = [ + { + type: "message", + role: "assistant", + content: "compacted summary", + }, + ]; + + vi.mocked(fetchHelpers.transformRequestForCodex).mockResolvedValueOnce({ + updatedInit: { + method: "POST", + body: JSON.stringify({ model: "gpt-5-mini", input: longInput }), + }, + body: { model: "gpt-5-mini", input: longInput }, + deferredFastSessionInputTrim: { maxItems: 8, preferLatestUserOnly: false }, + }); + + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ output: compactedInput }), { status: 200 }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ content: "ok" }), { status: 200 }), + ); + + const { sdk } = await setupPlugin(); + const response = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5-mini", input: longInput }), + }); + + expect(response.status).toBe(200); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + expect(vi.mocked(globalThis.fetch).mock.calls[0]?.[0]).toBe( + "https://api.openai.com/v1/chat/compact", + ); + + const upstreamInit = vi.mocked(globalThis.fetch).mock.calls[1]?.[1] as RequestInit; + const upstreamBody = + typeof upstreamInit.body === "string" + ? (JSON.parse(upstreamInit.body) as { input?: unknown[] }) + : {}; + expect(upstreamBody.input).toEqual(compactedInput); + }); + it("uses the refreshed token email when checking entitlement blocks", async () => { const { AccountManager } = await import("../lib/accounts.js"); const manager = buildRoutingManager([ diff --git a/test/model-map.test.ts b/test/model-map.test.ts index 6ad16967..7d2f8adb 100644 --- a/test/model-map.test.ts +++ b/test/model-map.test.ts @@ -84,14 +84,22 @@ describe("model map", () => { expect(getModelCapabilities("gpt-5.4")).toEqual({ toolSearch: true, computerUse: true, + compaction: true, }); expect(getModelCapabilities("gpt-5.4-pro")).toEqual({ toolSearch: false, computerUse: true, + compaction: true, }); expect(getModelCapabilities("gpt-5-mini")).toEqual({ + toolSearch: true, + computerUse: true, + compaction: true, + }); + expect(getModelCapabilities("gpt-5-nano")).toEqual({ toolSearch: false, computerUse: false, + compaction: true, }); }); }); diff --git a/test/request-transformer.test.ts b/test/request-transformer.test.ts index 51eb1214..17efbbcf 100644 --- a/test/request-transformer.test.ts +++ b/test/request-transformer.test.ts @@ -653,9 +653,31 @@ describe('Request Transformer Module', () => { }, }, }; - const result = await transformRequestBody(body, codexInstructions); - expect(result.text?.verbosity).toBe('medium'); - expect(result.text?.format).toEqual(body.text?.format); + const result = await transformRequestBody(body, codexInstructions); + expect(result.text?.verbosity).toBe('medium'); + expect(result.text?.format).toEqual(body.text?.format); + }); + + it('defers fast-session input trimming when requested for downstream compaction', async () => { + const body: RequestBody = { + model: 'gpt-5.4', + input: Array.from({ length: 12 }, (_value, index) => ({ + type: 'message', + role: index === 0 ? 'developer' : 'user', + content: index === 0 ? 'system prompt' : `message-${index}`, + })), + }; + const result = await transformRequestBody( + body, + codexInstructions, + { global: {}, models: {} }, + true, + true, + 'always', + 8, + true, + ); + expect(result.input).toHaveLength(12); }); it('should set required Codex fields', async () => { diff --git a/test/response-compaction.test.ts b/test/response-compaction.test.ts new file mode 100644 index 00000000..649532ee --- /dev/null +++ b/test/response-compaction.test.ts @@ -0,0 +1,115 @@ +import { applyResponseCompaction } from "../lib/request/response-compaction.js"; +import type { RequestBody } from "../lib/types.js"; + +function buildInput(length: number) { + return Array.from({ length }, (_value, index) => ({ + type: "message", + role: index === 0 ? "developer" : "user", + content: index === 0 ? "system prompt" : `message-${index}`, + })); +} + +describe("response compaction", () => { + it("returns unchanged when the fast-session trim would be a no-op", async () => { + const body: RequestBody = { + model: "gpt-5.4", + input: buildInput(2), + }; + const fetchImpl = vi.fn(); + + const result = await applyResponseCompaction({ + body, + requestUrl: "https://chatgpt.com/backend-api/codex/responses", + headers: new Headers(), + trim: { maxItems: 8, preferLatestUserOnly: false }, + fetchImpl, + }); + + expect(result.mode).toBe("unchanged"); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(result.body.input).toEqual(body.input); + }); + + it("falls back to local trimming when the model does not support compaction", async () => { + const body: RequestBody = { + model: "gpt-5-codex", + input: buildInput(10), + }; + const fetchImpl = vi.fn(); + + const result = await applyResponseCompaction({ + body, + requestUrl: "https://chatgpt.com/backend-api/codex/responses", + headers: new Headers(), + trim: { maxItems: 8, preferLatestUserOnly: false }, + fetchImpl, + }); + + expect(result.mode).toBe("trimmed"); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(result.body.input).toHaveLength(8); + }); + + it("replaces request input with server-compacted output when available", async () => { + const compactedOutput = [ + { + type: "message", + role: "assistant", + content: "compacted summary", + }, + ]; + const body: RequestBody = { + model: "gpt-5-mini", + input: buildInput(12), + }; + const fetchImpl = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ output: compactedOutput }), { status: 200 }), + ); + + const result = await applyResponseCompaction({ + body, + requestUrl: "https://chatgpt.com/backend-api/codex/responses", + headers: new Headers({ accept: "text/event-stream" }), + trim: { maxItems: 8, preferLatestUserOnly: false }, + fetchImpl, + }); + + expect(result.mode).toBe("compacted"); + expect(result.body.input).toEqual(compactedOutput); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(fetchImpl).toHaveBeenCalledWith( + "https://chatgpt.com/backend-api/codex/responses/compact", + expect.objectContaining({ + method: "POST", + headers: expect.any(Headers), + }), + ); + + const requestInit = vi.mocked(fetchImpl).mock.calls[0]?.[1]; + const headers = new Headers(requestInit?.headers); + expect(headers.get("accept")).toBe("application/json"); + expect(headers.get("content-type")).toBe("application/json"); + }); + + it("falls back to local trimming when the compaction request fails", async () => { + const body: RequestBody = { + model: "gpt-5.4", + input: buildInput(12), + }; + const fetchImpl = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: { message: "nope" } }), { status: 404 }), + ); + + const result = await applyResponseCompaction({ + body, + requestUrl: "https://chatgpt.com/backend-api/codex/responses", + headers: new Headers(), + trim: { maxItems: 8, preferLatestUserOnly: false }, + fetchImpl, + }); + + expect(result.mode).toBe("trimmed"); + expect(result.body.input).toHaveLength(8); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); +}); From 21df8136a0a76323541e4d96fe1fbc9a972bce58 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 16:40:41 +0800 Subject: [PATCH 311/376] type responses text format and prompt cache retention --- lib/request/request-transformer.ts | 18 +++++++++++++ lib/types.ts | 10 ++++++- test/public-api-contract.test.ts | 15 +++++++++++ test/request-transformer.test.ts | 42 ++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 1 deletion(-) diff --git a/lib/request/request-transformer.ts b/lib/request/request-transformer.ts index 3f6a3353..5a407f55 100644 --- a/lib/request/request-transformer.ts +++ b/lib/request/request-transformer.ts @@ -198,6 +198,18 @@ function resolveTextVerbosity( ); } +function resolvePromptCacheRetention( + modelConfig: ConfigOptions, + body: RequestBody, +): RequestBody["prompt_cache_retention"] { + const providerOpenAI = body.providerOptions?.openai; + return ( + body.prompt_cache_retention ?? + providerOpenAI?.promptCacheRetention ?? + modelConfig.promptCacheRetention + ); +} + function resolveInclude(modelConfig: ConfigOptions, body: RequestBody): string[] { const providerOpenAI = body.providerOptions?.openai; const base = @@ -899,11 +911,17 @@ export async function transformRequestBody( // Configure text verbosity (support user config) // Default: "medium" (matches Codex CLI default for all GPT-5 models) + // Preserve any structured-output `text.format` contract from the host. body.text = { ...body.text, verbosity: resolveTextVerbosity(modelConfig, body), }; + const promptCacheRetention = resolvePromptCacheRetention(modelConfig, body); + if (promptCacheRetention !== undefined) { + body.prompt_cache_retention = promptCacheRetention; + } + if (shouldApplyFastSessionTuning) { // In fast-session mode, prioritize speed by clamping to minimum reasoning + verbosity. // getReasoningConfig normalizes unsupported values per model family. diff --git a/lib/types.ts b/lib/types.ts index 1feeb8a9..17323401 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -24,9 +24,17 @@ export interface ConfigOptions { reasoningEffort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; reasoningSummary?: "auto" | "concise" | "detailed" | "off" | "on"; textVerbosity?: "low" | "medium" | "high"; + promptCacheRetention?: PromptCacheRetention; include?: string[]; } +export type PromptCacheRetention = + | "5m" + | "1h" + | "24h" + | "7d" + | (string & {}); + export interface ReasoningConfig { effort: "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; summary: "auto" | "concise" | "detailed"; @@ -131,7 +139,7 @@ export interface RequestBody { /** Stable key to enable prompt-token caching on Codex backend */ prompt_cache_key?: string; /** Retention mode for server-side prompt cache entries */ - prompt_cache_retention?: string; + prompt_cache_retention?: PromptCacheRetention; /** Resume a prior Responses API turn without resending the full transcript */ previous_response_id?: string; max_output_tokens?: number; diff --git a/test/public-api-contract.test.ts b/test/public-api-contract.test.ts index 307093f3..89aa891a 100644 --- a/test/public-api-contract.test.ts +++ b/test/public-api-contract.test.ts @@ -116,6 +116,21 @@ describe("public api contract", () => { const baseBody: RequestBody = { model: "gpt-5-codex", input: [{ type: "message", role: "user", content: "hi" }], + prompt_cache_retention: "24h", + text: { + format: { + type: "json_schema", + name: "compat_response", + schema: { + type: "object", + properties: { + answer: { type: "string" }, + }, + required: ["answer"], + }, + strict: true, + }, + }, }; const transformedPositional = await transformRequestBody( JSON.parse(JSON.stringify(baseBody)) as RequestBody, diff --git a/test/request-transformer.test.ts b/test/request-transformer.test.ts index 17efbbcf..a43b3c1e 100644 --- a/test/request-transformer.test.ts +++ b/test/request-transformer.test.ts @@ -634,6 +634,35 @@ describe('Request Transformer Module', () => { expect(result.prompt_cache_retention).toBe('24h'); }); + it('uses prompt_cache_retention from providerOptions when body omits it', async () => { + const body: RequestBody = { + model: 'gpt-5.4', + input: [], + providerOptions: { + openai: { + promptCacheRetention: '1h', + }, + }, + }; + const result = await transformRequestBody(body, codexInstructions); + expect(result.prompt_cache_retention).toBe('1h'); + }); + + it('prefers body prompt_cache_retention over providerOptions', async () => { + const body: RequestBody = { + model: 'gpt-5.4', + input: [], + prompt_cache_retention: '24h', + providerOptions: { + openai: { + promptCacheRetention: '1h', + }, + }, + }; + const result = await transformRequestBody(body, codexInstructions); + expect(result.prompt_cache_retention).toBe('24h'); + }); + it('preserves text.format when applying text verbosity defaults', async () => { const body: RequestBody = { model: 'gpt-5.4', @@ -1254,6 +1283,19 @@ describe('Request Transformer Module', () => { expect(result.text?.verbosity).toBe('low'); }); + it('should inherit prompt_cache_retention from user config', async () => { + const body: RequestBody = { + model: 'gpt-5.4', + input: [], + }; + const userConfig: UserConfig = { + global: { promptCacheRetention: '7d' }, + models: {}, + }; + const result = await transformRequestBody(body, codexInstructions, userConfig); + expect(result.prompt_cache_retention).toBe('7d'); + }); + it('should prefer body text verbosity over providerOptions', async () => { const body: RequestBody = { model: 'gpt-5', From 456cbaf3ea7d944580cfe5533dffe2df31adb098 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 16:57:48 +0800 Subject: [PATCH 312/376] Add provider prompt cache precedence coverage --- test/request-transformer.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/request-transformer.test.ts b/test/request-transformer.test.ts index a43b3c1e..98dc4d2f 100644 --- a/test/request-transformer.test.ts +++ b/test/request-transformer.test.ts @@ -648,6 +648,24 @@ describe('Request Transformer Module', () => { expect(result.prompt_cache_retention).toBe('1h'); }); + it('prefers providerOptions prompt_cache_retention over user config defaults', async () => { + const body: RequestBody = { + model: 'gpt-5.4', + input: [], + providerOptions: { + openai: { + promptCacheRetention: '1h', + }, + }, + }; + const userConfig: UserConfig = { + global: { promptCacheRetention: '7d' }, + models: {}, + }; + const result = await transformRequestBody(body, codexInstructions, userConfig); + expect(result.prompt_cache_retention).toBe('1h'); + }); + it('prefers body prompt_cache_retention over providerOptions', async () => { const body: RequestBody = { model: 'gpt-5.4', From cccf97261c90d4d88bec6f4a963e018e8234d16f Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 17:27:07 +0800 Subject: [PATCH 313/376] Type GPT-5.4 hosted tool definitions --- lib/request/helpers/tool-utils.ts | 89 ++++++++++++++++-------------- lib/request/request-transformer.ts | 71 ++++++++++++++++++++++++ lib/types.ts | 70 ++++++++++++++++++++++- test/public-api-contract.test.ts | 14 ++++- test/request-transformer.test.ts | 83 ++++++++++++++++++++++++++++ test/tool-utils.test.ts | 57 +++++++++++++++++++ 6 files changed, 340 insertions(+), 44 deletions(-) diff --git a/lib/request/helpers/tool-utils.ts b/lib/request/helpers/tool-utils.ts index 7c14ec78..414f166c 100644 --- a/lib/request/helpers/tool-utils.ts +++ b/lib/request/helpers/tool-utils.ts @@ -1,20 +1,9 @@ import { isRecord } from "../../utils.js"; - -export interface ToolFunction { - name: string; - description?: string; - parameters?: { - type: "object"; - properties?: Record; - required?: string[]; - [key: string]: unknown; - }; -} - -export interface Tool { - type: "function"; - function: ToolFunction; -} +import type { + FunctionToolDefinition, + RequestToolDefinition, + ToolParametersSchema, +} from "../../types.js"; function cloneRecord(value: Record): Record { return JSON.parse(JSON.stringify(value)) as Record; @@ -36,36 +25,54 @@ function cloneRecord(value: Record): Record { export function cleanupToolDefinitions(tools: unknown): unknown { if (!Array.isArray(tools)) return tools; - return tools.map((tool) => { - if (!isRecord(tool) || tool.type !== "function") { - return tool; - } - const functionDef = tool.function; - if (!isRecord(functionDef)) { - return tool; - } - const parameters = functionDef.parameters; - if (!isRecord(parameters)) { - return tool; - } + return tools.map((tool) => cleanupToolDefinition(tool)); +} - // Clone only the schema tree we mutate to avoid heavy deep cloning of entire tools. - let cleanedParameters: Record; - try { - cleanedParameters = cloneRecord(parameters); - } catch { - return tool; - } - cleanupSchema(cleanedParameters); +function cleanupToolDefinition(tool: unknown): unknown { + if (!isRecord(tool)) { + return tool; + } + if (tool.type === "function") { + return cleanupFunctionTool(tool as FunctionToolDefinition); + } + + if (tool.type === "namespace" && Array.isArray(tool.tools)) { return { ...tool, - function: { - ...functionDef, - parameters: cleanedParameters, - }, + tools: tool.tools.map((nestedTool) => cleanupToolDefinition(nestedTool)) as RequestToolDefinition[], }; - }); + } + + return tool; +} + +function cleanupFunctionTool(tool: FunctionToolDefinition): FunctionToolDefinition { + const functionDef = tool.function; + if (!isRecord(functionDef)) { + return tool; + } + const parameters = functionDef.parameters; + if (!isRecord(parameters)) { + return tool; + } + + // Clone only the schema tree we mutate to avoid heavy deep cloning of entire tools. + let cleanedParameters: Record; + try { + cleanedParameters = cloneRecord(parameters); + } catch { + return tool; + } + cleanupSchema(cleanedParameters); + + return { + ...tool, + function: { + ...functionDef, + parameters: cleanedParameters as ToolParametersSchema, + }, + }; } /** diff --git a/lib/request/request-transformer.ts b/lib/request/request-transformer.ts index 5a407f55..a117ae73 100644 --- a/lib/request/request-transformer.ts +++ b/lib/request/request-transformer.ts @@ -3,6 +3,7 @@ import { TOOL_REMAP_MESSAGE } from "../prompts/codex.js"; import { CODEX_HOST_BRIDGE } from "../prompts/codex-host-bridge.js"; import { getHostCodexPrompt } from "../prompts/host-codex-prompt.js"; import { + getModelCapabilities, getModelProfile, resolveNormalizedModel, type ModelReasoningEffort, @@ -24,6 +25,10 @@ import type { type CollaborationMode = "plan" | "default" | "unknown"; type FastSessionStrategy = "hybrid" | "always"; type SupportedReasoningSummary = "auto" | "concise" | "detailed"; +type ToolCapabilityRemovalCounts = { + toolSearch: number; + computerUse: number; +}; export interface TransformRequestBodyParams { body: RequestBody; @@ -297,6 +302,71 @@ function sanitizePlanOnlyTools(tools: unknown, mode: CollaborationMode): unknown return filtered; } +const COMPUTER_TOOL_TYPES = new Set(["computer", "computer_use_preview"]); + +function sanitizeModelIncompatibleTools(tools: unknown, model: string | undefined): unknown { + if (!Array.isArray(tools)) return tools; + + const capabilities = getModelCapabilities(model); + const removed: ToolCapabilityRemovalCounts = { + toolSearch: 0, + computerUse: 0, + }; + const filtered = tools + .map((tool) => sanitizeModelIncompatibleToolEntry(tool, capabilities, removed)) + .filter((tool) => tool !== null); + + if (removed.toolSearch > 0) { + logWarn( + `Removed ${removed.toolSearch} tool_search definition(s) because ${model ?? "the selected model"} does not support tool search`, + ); + } + if (removed.computerUse > 0) { + logWarn( + `Removed ${removed.computerUse} computer tool definition(s) because ${model ?? "the selected model"} does not support computer use`, + ); + } + + return filtered; +} + +function sanitizeModelIncompatibleToolEntry( + tool: unknown, + capabilities: ReturnType, + removed: ToolCapabilityRemovalCounts, +): unknown | null { + if (!tool || typeof tool !== "object") { + return tool; + } + + const record = tool as Record; + const type = typeof record.type === "string" ? record.type : ""; + if (type === "tool_search" && !capabilities.toolSearch) { + removed.toolSearch += 1; + return null; + } + if (COMPUTER_TOOL_TYPES.has(type) && !capabilities.computerUse) { + removed.computerUse += 1; + return null; + } + if (type === "namespace" && Array.isArray(record.tools)) { + const nestedTools = record.tools + .map((nestedTool) => sanitizeModelIncompatibleToolEntry(nestedTool, capabilities, removed)) + .filter((nestedTool) => nestedTool !== null); + if (nestedTools.length === 0) { + return null; + } + if (nestedTools.length === record.tools.length) { + return tool; + } + return { + ...record, + tools: nestedTools, + }; + } + return tool; +} + /** * Configure reasoning parameters based on model variant and user config * @@ -831,6 +901,7 @@ export async function transformRequestBody( if (body.tools) { body.tools = cleanupToolDefinitions(body.tools); body.tools = sanitizePlanOnlyTools(body.tools, collaborationMode); + body.tools = sanitizeModelIncompatibleTools(body.tools, body.model); } body.instructions = shouldApplyFastSessionTuning diff --git a/lib/types.ts b/lib/types.ts index 17323401..589ff556 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -40,6 +40,74 @@ export interface ReasoningConfig { summary: "auto" | "concise" | "detailed"; } +export interface ToolParametersSchema { + type: "object"; + properties?: Record; + required?: string[]; + [key: string]: unknown; +} + +export interface ToolFunction { + name: string; + description?: string; + parameters?: ToolParametersSchema; + [key: string]: unknown; +} + +export interface FunctionToolDefinition { + type: "function"; + function: ToolFunction; + defer_loading?: boolean; + [key: string]: unknown; +} + +export interface ToolSearchToolDefinition { + type: "tool_search"; + max_num_results?: number; + search_context_size?: "low" | "medium" | "high"; + filters?: Record; + [key: string]: unknown; +} + +export interface RemoteMcpToolDefinition { + type: "mcp"; + server_label?: string; + server_url?: string; + connector_id?: string; + headers?: Record; + allowed_tools?: string[]; + require_approval?: "never" | "always" | "auto" | Record; + defer_loading?: boolean; + [key: string]: unknown; +} + +export interface ComputerUseToolDefinition { + type: "computer" | "computer_use_preview"; + display_width?: number; + display_height?: number; + environment?: string; + [key: string]: unknown; +} + +export interface ToolNamespaceDefinition { + type: "namespace"; + name?: string; + description?: string; + tools?: RequestToolDefinition[]; + [key: string]: unknown; +} + +export type RequestToolDefinition = + | FunctionToolDefinition + | ToolSearchToolDefinition + | RemoteMcpToolDefinition + | ComputerUseToolDefinition + | ToolNamespaceDefinition + | { + type?: string; + [key: string]: unknown; + }; + export type TextFormatConfig = | { type: "text"; @@ -125,7 +193,7 @@ export interface RequestBody { stream?: boolean; instructions?: string; input?: InputItem[]; - tools?: unknown; + tools?: RequestToolDefinition[] | unknown; reasoning?: Partial; text?: { verbosity?: "low" | "medium" | "high"; diff --git a/test/public-api-contract.test.ts b/test/public-api-contract.test.ts index 89aa891a..6419b38b 100644 --- a/test/public-api-contract.test.ts +++ b/test/public-api-contract.test.ts @@ -12,7 +12,7 @@ import { getRateLimitBackoffWithReason, } from "../lib/request/rate-limit-backoff.js"; import { transformRequestBody } from "../lib/request/request-transformer.js"; -import type { RequestBody } from "../lib/types.js"; +import type { RequestBody, RequestToolDefinition } from "../lib/types.js"; describe("public api contract", () => { it("keeps root plugin exports aligned", async () => { @@ -114,9 +114,18 @@ describe("public api contract", () => { expect(rateNamed).toEqual(ratePositional); const baseBody: RequestBody = { - model: "gpt-5-codex", + model: "gpt-5.4", input: [{ type: "message", role: "user", content: "hi" }], prompt_cache_retention: "24h", + tools: [ + { type: "tool_search", max_num_results: 2 }, + { + type: "mcp", + server_label: "docs", + server_url: "https://mcp.example.com", + defer_loading: true, + }, + ] satisfies RequestToolDefinition[], text: { format: { type: "json_schema", @@ -141,5 +150,6 @@ describe("public api contract", () => { codexInstructions: "codex", }); expect(transformedNamed).toEqual(transformedPositional); + expect(transformedNamed.tools).toEqual(baseBody.tools); }); }); diff --git a/test/request-transformer.test.ts b/test/request-transformer.test.ts index 98dc4d2f..74eb55ef 100644 --- a/test/request-transformer.test.ts +++ b/test/request-transformer.test.ts @@ -1962,6 +1962,89 @@ describe('Request Transformer Module', () => { expect(toolNames).toEqual(['request_user_input']); }); + + it('removes tool_search tools when the selected model lacks search capability', async () => { + const body: RequestBody = { + model: 'gpt-5-nano', + input: [], + tools: [ + { type: 'tool_search', max_num_results: 3 }, + { + type: 'mcp', + server_label: 'docs', + server_url: 'https://mcp.example.com', + defer_loading: true, + }, + ] as any, + }; + + const result = await transformRequestBody(body, codexInstructions); + expect(result.tools).toEqual([ + { + type: 'mcp', + server_label: 'docs', + server_url: 'https://mcp.example.com', + defer_loading: true, + }, + ]); + }); + + it('removes computer tools when the selected model lacks computer-use capability', async () => { + const body: RequestBody = { + model: 'gpt-5-nano', + input: [], + tools: [ + { + type: 'computer_use_preview', + display_width: 1024, + display_height: 768, + environment: 'browser', + }, + { type: 'tool_search', max_num_results: 1 }, + ] as any, + }; + + const result = await transformRequestBody(body, codexInstructions); + expect(result.tools).toEqual([]); + }); + + it('filters unsupported namespace tool entries while keeping supported remote MCP tools', async () => { + const body: RequestBody = { + model: 'gpt-5-nano', + input: [], + tools: [ + { + type: 'namespace', + name: 'search_suite', + tools: [ + { type: 'tool_search', max_num_results: 2 }, + { + type: 'mcp', + server_label: 'remote-docs', + server_url: 'https://mcp.example.com', + defer_loading: true, + }, + ], + }, + ] as any, + }; + + const result = await transformRequestBody(body, codexInstructions); + expect(result.tools).toEqual([ + { + type: 'namespace', + name: 'search_suite', + tools: [ + { + type: 'mcp', + server_label: 'remote-docs', + server_url: 'https://mcp.example.com', + defer_loading: true, + }, + ], + }, + ]); + }); }); // NEW: Integration tests for all config scenarios diff --git a/test/tool-utils.test.ts b/test/tool-utils.test.ts index 31988238..b150c44e 100644 --- a/test/tool-utils.test.ts +++ b/test/tool-utils.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { cleanupToolDefinitions } from "../lib/request/helpers/tool-utils.js"; +import type { RequestToolDefinition } from "../lib/types.js"; describe("cleanupToolDefinitions", () => { it("returns non-array input unchanged", () => { @@ -13,6 +14,27 @@ describe("cleanupToolDefinitions", () => { expect(cleanupToolDefinitions(tools)).toEqual(tools); }); + it("preserves typed GPT-5.4 hosted tools unchanged", () => { + const tools: RequestToolDefinition[] = [ + { type: "tool_search", max_num_results: 3, search_context_size: "medium" }, + { + type: "mcp", + server_label: "docs", + server_url: "https://mcp.example.com", + defer_loading: true, + require_approval: "never", + }, + { + type: "computer_use_preview", + display_width: 1024, + display_height: 768, + environment: "browser", + }, + ]; + + expect(cleanupToolDefinitions(tools)).toEqual(tools); + }); + it("treats array parameters as non-records and leaves tool unchanged", () => { const tools = [{ type: "function", @@ -619,4 +641,39 @@ describe("cleanupToolDefinitions", () => { const props = result[0].function.parameters.properties as Record; expect(props.valid).toEqual({ type: "string" }); }); + + it("recursively cleans nested function tools inside namespace bundles", () => { + const tools: RequestToolDefinition[] = [ + { + type: "namespace", + name: "search_bundle", + tools: [ + { + type: "function", + function: { + name: "lookup", + parameters: { + type: "object", + properties: {}, + additionalProperties: false, + }, + }, + }, + { type: "tool_search", max_num_results: 2 }, + ], + }, + ]; + + const result = cleanupToolDefinitions(tools) as typeof tools; + const namespaceTools = result[0].tools ?? []; + const nestedFunction = namespaceTools[0] as Extract; + expect(nestedFunction.function.parameters?.additionalProperties).toBeUndefined(); + expect(nestedFunction.function.parameters?.properties).toEqual({ + _placeholder: { + type: "boolean", + description: "This property is a placeholder and should be ignored.", + }, + }); + expect(namespaceTools[1]).toEqual({ type: "tool_search", max_num_results: 2 }); + }); }); From 5d39a5974cfe5f1f763e6492d0589817f7fbdc8a Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 17:30:46 +0800 Subject: [PATCH 314/376] Log semantic response diagnostics --- lib/request/response-handler.ts | 30 +++++++++++++++++++++++ test/response-handler-logging.test.ts | 35 ++++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/lib/request/response-handler.ts b/lib/request/response-handler.ts index a36f89ae..8899f270 100644 --- a/lib/request/response-handler.ts +++ b/lib/request/response-handler.ts @@ -349,6 +349,34 @@ function notifyResponseId( } } +function truncateDiagnosticText(value: unknown, maxLength = 400): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + if (trimmed.length === 0) return undefined; + return trimmed.length > maxLength ? `${trimmed.slice(0, maxLength)}...` : trimmed; +} + +function logStreamDiagnostics(finalResponse: unknown): void { + if (!LOGGING_ENABLED || !isRecord(finalResponse)) { + return; + } + + const responseId = extractResponseId(finalResponse); + const phase = getStringField(finalResponse, "phase"); + const commentaryText = truncateDiagnosticText(finalResponse.commentary_text); + const finalAnswerText = truncateDiagnosticText(finalResponse.final_answer_text); + const reasoningSummaryText = truncateDiagnosticText(finalResponse.reasoning_summary_text); + if (phase || commentaryText || finalAnswerText || reasoningSummaryText) { + logRequest("stream-diagnostics", { + ...(responseId ? { responseId } : {}), + ...(phase ? { phase } : {}), + ...(commentaryText ? { commentaryText } : {}), + ...(finalAnswerText ? { finalAnswerText } : {}), + ...(reasoningSummaryText ? { reasoningSummaryText } : {}), + }); + } +} + function maybeCaptureResponseEvent( state: ParsedResponseState, data: SSEEventData, @@ -539,6 +567,8 @@ export async function convertSseToJson( }); } + logStreamDiagnostics(finalResponse); + // Return as plain JSON (not SSE) const jsonHeaders = new Headers(headers); jsonHeaders.set('content-type', 'application/json; charset=utf-8'); diff --git a/test/response-handler-logging.test.ts b/test/response-handler-logging.test.ts index a295b754..70cfab80 100644 --- a/test/response-handler-logging.test.ts +++ b/test/response-handler-logging.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const logRequestMock = vi.fn(); @@ -14,6 +14,10 @@ vi.mock("../lib/logger.js", () => ({ })); describe("response handler logging branch", () => { + beforeEach(() => { + logRequestMock.mockClear(); + }); + it("logs full stream content when logging is enabled", async () => { const { convertSseToJson } = await import("../lib/request/response-handler.js"); const response = new Response( @@ -29,4 +33,33 @@ describe("response handler logging branch", () => { expect.objectContaining({ fullContent: expect.stringContaining("response.done") }), ); }); + + it("logs parsed phase and reasoning summary diagnostics when semantic SSE fields are present", async () => { + const { convertSseToJson } = await import("../lib/request/response-handler.js"); + const response = new Response( + [ + 'data: {"type":"response.created","response":{"id":"resp_diag_123","object":"response"}}', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_123","type":"message","role":"assistant","phase":"commentary"}}', + 'data: {"type":"response.output_text.done","output_index":0,"content_index":0,"text":"Thinking...","phase":"commentary"}', + 'data: {"type":"response.output_item.done","output_index":0,"item":{"id":"msg_123","type":"message","role":"assistant","phase":"final_answer"}}', + 'data: {"type":"response.output_text.done","output_index":0,"content_index":1,"text":"Done.","phase":"final_answer"}', + 'data: {"type":"response.reasoning_summary_text.done","output_index":1,"summary_index":0,"text":"Need more context."}', + 'data: {"type":"response.done","response":{"id":"resp_diag_123","object":"response"}}', + "", + ].join("\n"), + ); + + const result = await convertSseToJson(response, new Headers()); + expect(result.status).toBe(200); + expect(logRequestMock).toHaveBeenCalledWith( + "stream-diagnostics", + expect.objectContaining({ + responseId: "resp_diag_123", + phase: "final_answer", + commentaryText: "Thinking...", + finalAnswerText: "Done.", + reasoningSummaryText: "Need more context.", + }), + ); + }); }); From 60106b608df55f069bc46861075ea06aea7a51d0 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 17:41:06 +0800 Subject: [PATCH 315/376] Document responses contract additions --- docs/development/CONFIG_FIELDS.md | 2 ++ docs/reference/public-api.md | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/docs/development/CONFIG_FIELDS.md b/docs/development/CONFIG_FIELDS.md index 9a3ee4cf..f345e793 100644 --- a/docs/development/CONFIG_FIELDS.md +++ b/docs/development/CONFIG_FIELDS.md @@ -31,6 +31,7 @@ Used only for host plugin mode through the host runtime config file. | `reasoningEffort` | string | `none\|minimal\|low\|medium\|high\|xhigh` | Reasoning effort hint | | `reasoningSummary` | string | `auto\|concise\|detailed` | Summary detail hint | | `textVerbosity` | string | `low\|medium\|high` | Text verbosity target | +| `promptCacheRetention` | string | `5m\|1h\|24h\|7d` | Default server-side prompt cache retention when the request body omits `prompt_cache_retention` | | `include` | string[] | `reasoning.encrypted_content` | Extra payload include | | `store` | boolean | `false` | Required for stateless backend mode | @@ -74,6 +75,7 @@ Used only for host plugin mode through the host runtime config file. | `tokenRefreshSkewMs` | `60000` | | `sessionRecovery` | `true` | | `autoResume` | `true` | +| `responseContinuation` | `false` | | `proactiveRefreshGuardian` | `true` | | `proactiveRefreshIntervalMs` | `60000` | | `proactiveRefreshBufferMs` | `300000` | diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md index 865189ff..43ca69f4 100644 --- a/docs/reference/public-api.md +++ b/docs/reference/public-api.md @@ -63,6 +63,30 @@ Positional signatures are preserved for backward compatibility. --- +## Responses Contract Notes + +The request-transform layer intentionally preserves and/or normalizes modern Responses API fields that callers may already send through the host SDK. + +- `previous_response_id` is preserved when explicitly provided and may be auto-filled from plugin continuation state when `pluginConfig.responseContinuation` is enabled. +- `text.format` is preserved when verbosity defaults are applied, so structured-output contracts continue to flow through untouched. +- `prompt_cache_retention` is preserved from the request body and can fall back to `providerOptions.openai.promptCacheRetention` or user config defaults. +- Hosted built-in tool definitions are typed and supported for: + - `tool_search` + - remote `mcp` + - `computer` / `computer_use_preview` + - `namespace` bundles containing nested tools +- Unsupported hosted search/computer tools are filtered before the upstream request when the selected model profile does not advertise that capability. +- Semantic SSE parsing synthesizes compatibility fields such as: + - `output_text` + - `reasoning_summary_text` + - `commentary_text` + - `final_answer_text` + - `phase_text` + +These behaviors are compatibility guarantees for the current release line because they protect caller intent while keeping the plugin stateless against the ChatGPT Codex backend. + +--- + ## Semver Guidance - Breaking Tier A change: `MAJOR` From 9b6c224295bff56b176eefbed2050f5d17304033 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:20:14 +0800 Subject: [PATCH 316/376] fix: harden extracted runtime toast helper --- lib/runtime/toast.ts | 5 +++- test/runtime-toast.test.ts | 48 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 test/runtime-toast.test.ts diff --git a/lib/runtime/toast.ts b/lib/runtime/toast.ts index d4c19e18..75731743 100644 --- a/lib/runtime/toast.ts +++ b/lib/runtime/toast.ts @@ -21,7 +21,10 @@ export async function showRuntimeToast( message, variant, ...(options?.title && { title: options.title }), - ...(options?.duration && { duration: options.duration }), + ...(options?.duration !== undefined && + options.duration !== null && { + duration: options.duration, + }), }, }); } catch { diff --git a/test/runtime-toast.test.ts b/test/runtime-toast.test.ts new file mode 100644 index 00000000..7bf16a43 --- /dev/null +++ b/test/runtime-toast.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; + +import { showRuntimeToast } from "../lib/runtime/toast.js"; + +describe("showRuntimeToast", () => { + it("passes through an explicit zero duration", async () => { + const showToast = vi.fn().mockResolvedValue(undefined); + + await showRuntimeToast( + { tui: { showToast } }, + "hello", + "info", + { duration: 0 }, + ); + + expect(showToast).toHaveBeenCalledWith({ + body: { + message: "hello", + variant: "info", + duration: 0, + }, + }); + }); + + it("omits title and duration when they are not provided", async () => { + const showToast = vi.fn().mockResolvedValue(undefined); + + await showRuntimeToast({ tui: { showToast } }, "hello"); + + expect(showToast).toHaveBeenCalledWith({ + body: { + message: "hello", + variant: "success", + }, + }); + }); + + it("swallows TUI errors", async () => { + const showToast = vi.fn().mockRejectedValue(new Error("toast failed")); + + await expect( + showRuntimeToast({ tui: { showToast } }, "hello", "warning", { + title: "Heads up", + duration: 2500, + }), + ).resolves.toBeUndefined(); + }); +}); From 16bbcc6c6e03af8c9287bca38d309ef27828341e Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 18:21:53 +0800 Subject: [PATCH 317/376] Fix parity model normalization regressions --- lib/capability-policy.ts | 4 +- lib/codex-manager.ts | 11 +++-- lib/prompts/codex.ts | 9 +++- lib/request/helpers/model-map.ts | 5 +- test/capability-policy.test.ts | 10 ++++ test/codex-manager-cli.test.ts | 72 ++++++++++++++++++++++++++++ test/codex-prompts.test.ts | 82 +++++++++++++++++++++++++++++++- test/model-map.test.ts | 7 +++ 8 files changed, 190 insertions(+), 10 deletions(-) diff --git a/lib/capability-policy.ts b/lib/capability-policy.ts index cbd51f09..eb9a7f92 100644 --- a/lib/capability-policy.ts +++ b/lib/capability-policy.ts @@ -1,4 +1,4 @@ -import { resolveNormalizedModel } from "./request/helpers/model-map.js"; +import { getNormalizedModel } from "./request/helpers/model-map.js"; export interface CapabilityPolicySnapshot { successes: number; @@ -33,7 +33,7 @@ function normalizeModel(model: string | undefined): string | null { const withoutProvider = trimmedInput.includes("/") ? (trimmedInput.split("/").pop() ?? trimmedInput) : trimmedInput; - const mapped = resolveNormalizedModel(withoutProvider); + const mapped = getNormalizedModel(withoutProvider) ?? withoutProvider; const trimmed = mapped.trim().toLowerCase(); if (!trimmed) return null; return trimmed.replace(/-(none|minimal|low|medium|high|xhigh)$/i, ""); diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 6e36b80d..34e82ed2 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -2609,6 +2609,7 @@ async function runForecast(args: string[]): Promise { } const options = parsedArgs.options; const display = DEFAULT_DASHBOARD_DISPLAY_SETTINGS; + const probeModel = inspectRequestedModel(options.model?.trim() || "gpt-5-codex").normalized; const quotaCache = options.live ? await loadQuotaCache() : null; const workingQuotaCache = quotaCache ? cloneQuotaCacheData(quotaCache) : null; let quotaCacheChanged = false; @@ -2659,7 +2660,7 @@ async function runForecast(args: string[]): Promise { const liveQuota = await fetchCodexQuotaSnapshot({ accountId: probeAccountId, accessToken: probeAccessToken, - model: options.model, + model: probeModel, }); liveQuotaByIndex.set(i, liveQuota); if (workingQuotaCache) { @@ -3410,6 +3411,7 @@ async function runFix(args: string[]): Promise { } const options = parsedArgs.options; const display = DEFAULT_DASHBOARD_DISPLAY_SETTINGS; + const probeModel = inspectRequestedModel(options.model?.trim() || "gpt-5-codex").normalized; const quotaCache = options.live ? await loadQuotaCache() : null; const workingQuotaCache = quotaCache ? cloneQuotaCacheData(quotaCache) : null; let quotaCacheChanged = false; @@ -3458,7 +3460,7 @@ async function runFix(args: string[]): Promise { const snapshot = await fetchCodexQuotaSnapshot({ accountId: probeAccountId, accessToken: currentAccessToken, - model: options.model, + model: probeModel, }); if (workingQuotaCache) { quotaCacheChanged = @@ -3554,7 +3556,7 @@ async function runFix(args: string[]): Promise { const snapshot = await fetchCodexQuotaSnapshot({ accountId: probeAccountId, accessToken: refreshResult.access, - model: options.model, + model: probeModel, }); if (workingQuotaCache) { quotaCacheChanged = @@ -4885,6 +4887,7 @@ async function runBest(args: string[]): Promise { } const now = Date.now(); + const probeModel = inspectRequestedModel(options.model?.trim() || "gpt-5-codex").normalized; const refreshFailures = new Map(); const liveQuotaByIndex = new Map>>(); const probeIdTokenByIndex = new Map(); @@ -4967,7 +4970,7 @@ async function runBest(args: string[]): Promise { const liveQuota = await fetchCodexQuotaSnapshot({ accountId: probeAccountId, accessToken: probeAccessToken, - model: options.model, + model: probeModel, }); liveQuotaByIndex.set(i, liveQuota); } catch (error) { diff --git a/lib/prompts/codex.ts b/lib/prompts/codex.ts index b21eab13..ceaa0d52 100644 --- a/lib/prompts/codex.ts +++ b/lib/prompts/codex.ts @@ -370,8 +370,15 @@ function refreshInstructionsInBackground( * Prewarm instruction caches for the provided models/families. */ export function prewarmCodexInstructions(models: string[] = []): void { - const candidates = models.length > 0 ? models : ["gpt-5-codex", "gpt-5.4", "gpt-5.2"]; + const candidates = models.length > 0 ? models : ["gpt-5-codex", "gpt-5.4", "gpt-5.1"]; + const prewarmTargets = new Map(); for (const model of candidates) { + const promptFamily = getModelFamily(model); + if (!prewarmTargets.has(promptFamily)) { + prewarmTargets.set(promptFamily, model); + } + } + for (const model of prewarmTargets.values()) { void getCodexInstructions(model).catch((error) => { logDebug("Codex instruction prewarm failed", { model, diff --git a/lib/request/helpers/model-map.ts b/lib/request/helpers/model-map.ts index 20a6832d..f229cc98 100644 --- a/lib/request/helpers/model-map.ts +++ b/lib/request/helpers/model-map.ts @@ -180,8 +180,8 @@ function addGeneralAliases(): void { addReasoningAliases("gpt-5-mini", "gpt-5-mini"); addReasoningAliases("gpt-5-nano", "gpt-5-nano"); - addAlias("gpt-5.1-chat-latest", "gpt-5.1"); - addAlias("gpt-5-chat-latest", "gpt-5"); + addReasoningAliases("gpt-5.1-chat-latest", "gpt-5.1"); + addReasoningAliases("gpt-5-chat-latest", "gpt-5"); addReasoningAliases("gpt-5.4-mini", "gpt-5-mini"); addReasoningAliases("gpt-5.4-nano", "gpt-5-nano"); } @@ -195,6 +195,7 @@ function addCodexAliases(): void { addAlias("gpt_5_codex", "gpt-5-codex"); addReasoningAliases("gpt-5.1-codex-max", "gpt-5.1-codex-max"); + addAlias("codex-max", "gpt-5.1-codex-max"); addAlias("codex-mini-latest", "gpt-5.1-codex-mini"); addReasoningAliases("gpt-5-codex-mini", "gpt-5.1-codex-mini"); diff --git a/test/capability-policy.test.ts b/test/capability-policy.test.ts index 9675a283..34db5fda 100644 --- a/test/capability-policy.test.ts +++ b/test/capability-policy.test.ts @@ -61,6 +61,16 @@ describe("capability policy store", () => { expect(snapshot?.successes).toBe(1); }); + it("keeps unknown model identifiers in separate capability buckets", () => { + const store = new CapabilityPolicyStore(); + store.recordSuccess("id:acc_unknown", "claude-3-sonnet-high", 1_000); + + expect(store.getSnapshot("id:acc_unknown", "claude-3-sonnet")).toMatchObject({ + successes: 1, + }); + expect(store.getSnapshot("id:acc_unknown", "gpt-5.4")).toBeNull(); + }); + it("ignores blank model and blank account writes", () => { const store = new CapabilityPolicyStore(); store.recordSuccess("", "gpt-5-codex", 1_000); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 613d6c93..ba887689 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -5775,6 +5775,78 @@ describe("codex manager cli commands", () => { }); }); + it("uses the same normalized backend model for live probes across report, forecast, best, and fix", async () => { + const now = Date.now(); + const storageState = { + version: 3 as const, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "probe@example.com", + accountId: "acc_probe", + refreshToken: "refresh-probe", + accessToken: "access-probe", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadQuotaCacheMock.mockResolvedValue({ byAccountId: {}, byEmail: {} }); + saveQuotaCacheMock.mockResolvedValue(undefined); + queuedRefreshMock.mockResolvedValue({ + type: "success", + access: "access-probe-refresh", + refresh: "refresh-probe-refresh", + expires: now + 7_200_000, + idToken: "id-probe-refresh", + }); + fetchCodexQuotaSnapshotMock.mockResolvedValue({ + status: 200, + model: "gpt-5-mini", + primary: { + usedPercent: 10, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 5, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }); + setCodexCliActiveSelectionMock.mockResolvedValue(true); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const commands = [ + ["auth", "report", "--live", "--json", "--model", "gpt-5.4-mini-high"], + ["auth", "forecast", "--live", "--json", "--model", "gpt-5.4-mini-high"], + ["auth", "best", "--live", "--model", "gpt-5.4-mini-high"], + ["auth", "fix", "--live", "--json", "--model", "gpt-5.4-mini-high"], + ]; + + for (const command of commands) { + fetchCodexQuotaSnapshotMock.mockClear(); + const exitCode = await runCodexMultiAuthCli(command); + + expect(exitCode).toBe(0); + expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalled(); + for (const [request] of fetchCodexQuotaSnapshotMock.mock.calls) { + expect(request).toMatchObject({ model: "gpt-5-mini" }); + } + } + + expect(errorSpy).not.toHaveBeenCalled(); + errorSpy.mockRestore(); + warnSpy.mockRestore(); + logSpy.mockRestore(); + }); + it("drives interactive settings hub across sections and persists dashboard/backend changes", async () => { const now = Date.now(); setupInteractiveSettingsLogin(createSettingsStorage(now)); diff --git a/test/codex-prompts.test.ts b/test/codex-prompts.test.ts index 60a190e4..845eb5ef 100644 --- a/test/codex-prompts.test.ts +++ b/test/codex-prompts.test.ts @@ -14,7 +14,14 @@ vi.mock("node:fs", () => ({ const originalFetch = global.fetch; let mockFetch: ReturnType; -import { getModelFamily, getCodexInstructions, MODEL_FAMILIES, TOOL_REMAP_MESSAGE, __clearCacheForTesting } from "../lib/prompts/codex.js"; +import { + __clearCacheForTesting, + getCodexInstructions, + getModelFamily, + MODEL_FAMILIES, + prewarmCodexInstructions, + TOOL_REMAP_MESSAGE, +} from "../lib/prompts/codex.js"; const mockedReadFile = vi.mocked(fs.readFile); const mockedWriteFile = vi.mocked(fs.writeFile); @@ -160,6 +167,12 @@ describe("Codex Prompts Module", () => { const result = await getCodexInstructions("codex-max"); expect(result).toBe("new instructions from github"); expect(mockFetch).toHaveBeenCalledTimes(2); + const rawGitHubCall = mockFetch.mock.calls.find( + (call) => + typeof call[0] === "string" && + call[0].includes("raw.githubusercontent.com"), + ); + expect(rawGitHubCall?.[0]).toContain("gpt-5.1-codex-max_prompt.md"); }); it("should handle 304 Not Modified response", async () => { @@ -243,6 +256,12 @@ describe("Codex Prompts Module", () => { const result = await getCodexInstructions("gpt-5.2-codex"); expect(result).toBe("fallback instructions"); + const rawGitHubCall = mockFetch.mock.calls.find( + (call) => + typeof call[0] === "string" && + call[0].includes("raw.githubusercontent.com"), + ); + expect(rawGitHubCall?.[0]).toContain("gpt_5_codex_prompt.md"); }); it("should parse tag from HTML content if URL parsing fails", async () => { @@ -366,6 +385,46 @@ describe("Codex Prompts Module", () => { const result = await getCodexInstructions("gpt-5.1"); expect(result).toBe("bundled fallback instructions"); }); + + it("prewarms unique prompt families once while retaining gpt-5.1 coverage", async () => { + mockedReadFile.mockRejectedValue(new Error("ENOENT")); + mockFetch.mockImplementation((input) => { + if (typeof input === "string" && input.includes("api.github.com")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ tag_name: "rust-v0.120.0" }), + }); + } + return Promise.resolve({ + ok: true, + text: () => Promise.resolve("prewarmed content"), + headers: { get: () => "etag" }, + }); + }); + mockedMkdir.mockResolvedValue(undefined); + mockedWriteFile.mockResolvedValue(undefined); + + prewarmCodexInstructions(); + + await vi.waitFor(() => { + const rawCalls = mockFetch.mock.calls.filter( + (call) => + typeof call[0] === "string" && + call[0].includes("raw.githubusercontent.com"), + ); + expect(rawCalls).toHaveLength(3); + }); + + const rawUrls = mockFetch.mock.calls + .map((call) => call[0]) + .filter( + (url): url is string => + typeof url === "string" && url.includes("raw.githubusercontent.com"), + ); + expect(rawUrls.filter((url) => url.includes("gpt_5_2_prompt.md"))).toHaveLength(1); + expect(rawUrls.some((url) => url.includes("gpt_5_codex_prompt.md"))).toBe(true); + expect(rawUrls.some((url) => url.includes("gpt_5_1_prompt.md"))).toBe(true); + }); }); describe("Cache size management", () => { @@ -484,6 +543,27 @@ describe("Codex Prompts Module", () => { ); expect(rawGitHubCall?.[0]).toContain("gpt_5_2_prompt.md"); }); + + it("should map gpt-5.2 prompts to the latest available general prompt file", async () => { + mockedReadFile.mockRejectedValue(new Error("ENOENT")); + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ tag_name: "rust-v0.116.0" }), + text: () => Promise.resolve("content"), + headers: { get: () => "etag" }, + }); + mockedMkdir.mockResolvedValue(undefined); + mockedWriteFile.mockResolvedValue(undefined); + + await getCodexInstructions("gpt-5.2"); + const fetchCalls = mockFetch.mock.calls; + const rawGitHubCall = fetchCalls.find( + (call) => + typeof call[0] === "string" && + call[0].includes("raw.githubusercontent.com"), + ); + expect(rawGitHubCall?.[0]).toContain("gpt_5_2_prompt.md"); + }); }); }); }); diff --git a/test/model-map.test.ts b/test/model-map.test.ts index 6ad16967..c071b372 100644 --- a/test/model-map.test.ts +++ b/test/model-map.test.ts @@ -31,6 +31,11 @@ describe("model map", () => { expect(MODEL_MAP["gpt-5.4-mini"]).toBe("gpt-5-mini"); expect(MODEL_MAP["gpt-5.4-nano"]).toBe("gpt-5-nano"); }); + + it("adds reasoning variants for legacy chat-latest aliases", () => { + expect(MODEL_MAP["gpt-5-chat-latest-high"]).toBe("gpt-5"); + expect(MODEL_MAP["gpt-5.1-chat-latest-minimal"]).toBe("gpt-5.1"); + }); }); describe("getNormalizedModel", () => { @@ -39,6 +44,8 @@ describe("model map", () => { expect(getNormalizedModel("GPT-5.4-PRO-HIGH")).toBe("gpt-5.4-pro"); expect(getNormalizedModel("gpt-5.4-mini")).toBe("gpt-5-mini"); expect(getNormalizedModel("gpt-5.3-codex-high")).toBe("gpt-5-codex"); + expect(getNormalizedModel("gpt-5-chat-latest-high")).toBe("gpt-5"); + expect(getNormalizedModel("codex-max")).toBe("gpt-5.1-codex-max"); }); it("returns undefined for unknown exact identifiers", () => { From e17a5c5e244e0584cd6613f1aaa0062b4f40ef3a Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:25:29 +0800 Subject: [PATCH 318/376] Harden prompt raw-path assertions --- test/codex-prompts.test.ts | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/test/codex-prompts.test.ts b/test/codex-prompts.test.ts index 845eb5ef..827c16de 100644 --- a/test/codex-prompts.test.ts +++ b/test/codex-prompts.test.ts @@ -167,12 +167,15 @@ describe("Codex Prompts Module", () => { const result = await getCodexInstructions("codex-max"); expect(result).toBe("new instructions from github"); expect(mockFetch).toHaveBeenCalledTimes(2); - const rawGitHubCall = mockFetch.mock.calls.find( - (call) => - typeof call[0] === "string" && - call[0].includes("raw.githubusercontent.com"), - ); - expect(rawGitHubCall?.[0]).toContain("gpt-5.1-codex-max_prompt.md"); + const rawGitHubUrls = mockFetch.mock.calls + .map((call) => call[0]) + .filter( + (url): url is string => + typeof url === "string" && url.includes("raw.githubusercontent.com"), + ); + expect( + rawGitHubUrls.some((url) => url.includes("gpt-5.1-codex-max_prompt.md")), + ).toBe(true); }); it("should handle 304 Not Modified response", async () => { @@ -256,12 +259,15 @@ describe("Codex Prompts Module", () => { const result = await getCodexInstructions("gpt-5.2-codex"); expect(result).toBe("fallback instructions"); - const rawGitHubCall = mockFetch.mock.calls.find( - (call) => - typeof call[0] === "string" && - call[0].includes("raw.githubusercontent.com"), + const rawGitHubUrls = mockFetch.mock.calls + .map((call) => call[0]) + .filter( + (url): url is string => + typeof url === "string" && url.includes("raw.githubusercontent.com"), + ); + expect(rawGitHubUrls.some((url) => url.includes("gpt_5_codex_prompt.md"))).toBe( + true, ); - expect(rawGitHubCall?.[0]).toContain("gpt_5_codex_prompt.md"); }); it("should parse tag from HTML content if URL parsing fails", async () => { From 280e0e09b0c09b424bab09d4b33f50c4f68d21f7 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:25:29 +0800 Subject: [PATCH 319/376] Harden prompt raw-path assertions --- test/codex-prompts.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/codex-prompts.test.ts b/test/codex-prompts.test.ts index 60a190e4..5bb9e334 100644 --- a/test/codex-prompts.test.ts +++ b/test/codex-prompts.test.ts @@ -160,6 +160,15 @@ describe("Codex Prompts Module", () => { const result = await getCodexInstructions("codex-max"); expect(result).toBe("new instructions from github"); expect(mockFetch).toHaveBeenCalledTimes(2); + const rawGitHubUrls = mockFetch.mock.calls + .map((call) => call[0]) + .filter( + (url): url is string => + typeof url === "string" && url.includes("raw.githubusercontent.com"), + ); + expect( + rawGitHubUrls.some((url) => url.includes("gpt-5.1-codex-max_prompt.md")), + ).toBe(true); }); it("should handle 304 Not Modified response", async () => { @@ -243,6 +252,15 @@ describe("Codex Prompts Module", () => { const result = await getCodexInstructions("gpt-5.2-codex"); expect(result).toBe("fallback instructions"); + const rawGitHubUrls = mockFetch.mock.calls + .map((call) => call[0]) + .filter( + (url): url is string => + typeof url === "string" && url.includes("raw.githubusercontent.com"), + ); + expect(rawGitHubUrls.some((url) => url.includes("gpt_5_codex_prompt.md"))).toBe( + true, + ); }); it("should parse tag from HTML content if URL parsing fails", async () => { From 5fa335a0622d1f54dd19210d89b2137376ee3ffd Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:30:31 +0800 Subject: [PATCH 320/376] test: cover runtime account-check helpers --- lib/runtime/quota-headers.ts | 32 +++++----- lib/runtime/quota-probe.ts | 9 +-- test/account-check-types.test.ts | 19 ++++++ test/quota-headers.test.ts | 102 +++++++++++++++++++++++++++++++ test/runtime-quota-probe.test.ts | 96 +++++++++++++++++++++++++++++ 5 files changed, 238 insertions(+), 20 deletions(-) create mode 100644 test/account-check-types.test.ts create mode 100644 test/quota-headers.test.ts create mode 100644 test/runtime-quota-probe.test.ts diff --git a/lib/runtime/quota-headers.ts b/lib/runtime/quota-headers.ts index fb5d7a95..395b3ce4 100644 --- a/lib/runtime/quota-headers.ts +++ b/lib/runtime/quota-headers.ts @@ -1,16 +1,8 @@ -export type CodexQuotaWindow = { - usedPercent?: number; - windowMinutes?: number; - resetAtMs?: number; -}; - -export type CodexQuotaSnapshot = { - status: number; - planType?: string; - activeLimit?: number; - primary: CodexQuotaWindow; - secondary: CodexQuotaWindow; -}; +import type { CodexQuotaSnapshot, CodexQuotaWindow } from "../quota-probe.js"; + +export type { CodexQuotaSnapshot, CodexQuotaWindow } from "../quota-probe.js"; + +export type ParsedCodexQuotaSnapshot = Omit; export function parseFiniteNumberHeader( headers: Headers, @@ -80,7 +72,7 @@ export function hasCodexQuotaHeaders(headers: Headers): boolean { export function parseCodexQuotaSnapshot( headers: Headers, status: number, -): CodexQuotaSnapshot | null { +): ParsedCodexQuotaSnapshot | null { if (!hasCodexQuotaHeaders(headers)) return null; const primaryPrefix = "x-codex-primary"; @@ -155,8 +147,16 @@ export function formatResetAt( return `${time} on ${day}`; } -export function formatCodexQuotaLine(snapshot: CodexQuotaSnapshot): string { - const summarizeWindow = (label: string, window: CodexQuotaWindow): string => { +export function formatCodexQuotaLine( + snapshot: Pick< + CodexQuotaSnapshot, + "status" | "planType" | "activeLimit" | "primary" | "secondary" + >, +): string { + const summarizeWindow = ( + label: string, + window: CodexQuotaSnapshot["primary"], + ): string => { const used = window.usedPercent; const left = typeof used === "number" && Number.isFinite(used) diff --git a/lib/runtime/quota-probe.ts b/lib/runtime/quota-probe.ts index ce4ed274..aff888a6 100644 --- a/lib/runtime/quota-probe.ts +++ b/lib/runtime/quota-probe.ts @@ -1,5 +1,6 @@ import type { RequestBody } from "../types.js"; -import type { CodexQuotaSnapshot } from "./quota-headers.js"; +import type { CodexQuotaSnapshot } from "../quota-probe.js"; +import type { ParsedCodexQuotaSnapshot } from "./quota-headers.js"; const QUOTA_PROBE_MODELS = [ "gpt-5-codex", @@ -22,7 +23,7 @@ export async function fetchRuntimeCodexQuotaSnapshot(params: { parseCodexQuotaSnapshot: ( headers: Headers, status: number, - ) => CodexQuotaSnapshot | null; + ) => ParsedCodexQuotaSnapshot | null; getUnsupportedCodexModelInfo: (errorBody: unknown) => { isUnsupported: boolean; message?: string; @@ -56,7 +57,7 @@ export async function fetchRuntimeCodexQuotaSnapshot(params: { params.accessToken, { model }, ); - headers.set("content-type", "application/json; charset=utf-8"); + headers.set("content-type", "application/json"); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 15_000); @@ -82,7 +83,7 @@ export async function fetchRuntimeCodexQuotaSnapshot(params: { } catch { // Ignore cancellation failures. } - return snapshot; + return { ...snapshot, model }; } if (!response.ok) { diff --git a/test/account-check-types.test.ts b/test/account-check-types.test.ts new file mode 100644 index 00000000..a6144360 --- /dev/null +++ b/test/account-check-types.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import { createAccountCheckWorkingState } from "../lib/runtime/account-check-types.js"; + +describe("createAccountCheckWorkingState", () => { + it("initializes empty counters, flags, and removal set", () => { + const flaggedStorage = { version: 1 as const, accounts: [] }; + + const state = createAccountCheckWorkingState(flaggedStorage); + + expect(state.storageChanged).toBe(false); + expect(state.flaggedChanged).toBe(false); + expect(state.ok).toBe(0); + expect(state.errors).toBe(0); + expect(state.disabled).toBe(0); + expect(state.removeFromActive.size).toBe(0); + expect(state.flaggedStorage).toBe(flaggedStorage); + }); +}); diff --git a/test/quota-headers.test.ts b/test/quota-headers.test.ts new file mode 100644 index 00000000..b90de739 --- /dev/null +++ b/test/quota-headers.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + formatCodexQuotaLine, + parseCodexQuotaSnapshot, + parseResetAtMs, +} from "../lib/runtime/quota-headers.js"; + +describe("runtime quota headers", () => { + it("returns null when no quota headers are present", () => { + expect(parseCodexQuotaSnapshot(new Headers(), 200)).toBeNull(); + }); + + it("parses reset-after, plan type, and active limit from quota headers", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-22T09:00:00.000Z")); + + const snapshot = parseCodexQuotaSnapshot( + new Headers({ + "x-codex-primary-used-percent": "32", + "x-codex-primary-window-minutes": "300", + "x-codex-primary-reset-after-seconds": "120", + "x-codex-secondary-used-percent": "64", + "x-codex-secondary-window-minutes": "10080", + "x-codex-secondary-reset-at": "2026-03-22T12:00:00.000Z", + "x-codex-plan-type": " plus ", + "x-codex-active-limit": "4", + }), + 429, + ); + + expect(snapshot).toMatchObject({ + status: 429, + planType: "plus", + activeLimit: 4, + primary: { + usedPercent: 32, + windowMinutes: 300, + resetAtMs: new Date("2026-03-22T09:02:00.000Z").getTime(), + }, + secondary: { + usedPercent: 64, + windowMinutes: 10080, + resetAtMs: new Date("2026-03-22T12:00:00.000Z").getTime(), + }, + }); + vi.useRealTimers(); + }); + + it("parses reset-at values expressed as epoch seconds and milliseconds", () => { + const epochSeconds = String(1_763_527_200); + const epochMilliseconds = String(1_763_527_200_000); + + expect( + parseResetAtMs( + new Headers({ "x-codex-primary-reset-at": epochSeconds }), + "x-codex-primary", + ), + ).toBe(1_763_527_200_000); + expect( + parseResetAtMs( + new Headers({ "x-codex-primary-reset-at": epochMilliseconds }), + "x-codex-primary", + ), + ).toBe(1_763_527_200_000); + expect( + parseResetAtMs( + new Headers({ "x-codex-primary-reset-at": "not-a-date" }), + "x-codex-primary", + ), + ).toBeUndefined(); + }); + + it("formats quota lines with labels, reset times, plan, active limit, and rate limit markers", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-22T09:00:00.000Z")); + + const line = formatCodexQuotaLine({ + status: 429, + planType: "pro", + activeLimit: 4, + primary: { + windowMinutes: 1440, + usedPercent: 60, + resetAtMs: new Date("2026-03-22T10:00:00.000Z").getTime(), + }, + secondary: { + windowMinutes: 60, + usedPercent: 10, + resetAtMs: new Date("2026-03-23T11:00:00.000Z").getTime(), + }, + }); + + expect(line).toContain("1d"); + expect(line).toContain("1h"); + expect(line).toContain("resets"); + expect(line).toContain("plan:pro"); + expect(line).toContain("active:4"); + expect(line).toContain("rate-limited"); + vi.useRealTimers(); + }); +}); diff --git a/test/runtime-quota-probe.test.ts b/test/runtime-quota-probe.test.ts new file mode 100644 index 00000000..e9da9e6f --- /dev/null +++ b/test/runtime-quota-probe.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from "vitest"; + +import { fetchRuntimeCodexQuotaSnapshot } from "../lib/runtime/quota-probe.js"; + +function makeQuotaHeaders(overrides: Record = {}): Headers { + return new Headers({ + "x-codex-primary-used-percent": "32", + "x-codex-primary-window-minutes": "300", + "x-codex-primary-reset-after-seconds": "120", + "x-codex-secondary-used-percent": "64", + "x-codex-secondary-window-minutes": "10080", + "x-codex-secondary-reset-after-seconds": "600", + "x-codex-plan-type": "plus", + ...overrides, + }); +} + +describe("fetchRuntimeCodexQuotaSnapshot", () => { + it("returns a parsed snapshot and preserves the winning model", async () => { + const parseCodexQuotaSnapshot = vi.fn((_headers: Headers, status: number) => ({ + status, + planType: "plus", + activeLimit: 2, + primary: { usedPercent: 32, windowMinutes: 300, resetAtMs: 1000 }, + secondary: { usedPercent: 64, windowMinutes: 10080, resetAtMs: 2000 }, + })); + const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => { + expect((init?.headers as Headers).get("content-type")).toBe( + "application/json", + ); + return new Response("", { status: 200, headers: makeQuotaHeaders() }); + }); + + const snapshot = await fetchRuntimeCodexQuotaSnapshot({ + accountId: "acc-1", + accessToken: "token-1", + baseUrl: "https://example.test", + fetchImpl, + getCodexInstructions: async (model: string) => `instructions:${model}`, + createCodexHeaders: () => new Headers(), + parseCodexQuotaSnapshot, + getUnsupportedCodexModelInfo: () => ({ isUnsupported: false }), + }); + + expect(snapshot.model).toBe("gpt-5-codex"); + expect(snapshot.planType).toBe("plus"); + expect(parseCodexQuotaSnapshot).toHaveBeenCalledOnce(); + }); + + it("falls back to the next model when the first one is unsupported", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ error: { message: "unsupported" } }), + { + status: 400, + headers: new Headers({ "content-type": "application/json" }), + }, + ), + ) + .mockResolvedValueOnce( + new Response("", { status: 200, headers: makeQuotaHeaders() }), + ); + + const snapshot = await fetchRuntimeCodexQuotaSnapshot({ + accountId: "acc-1", + accessToken: "token-1", + baseUrl: "https://example.test", + fetchImpl, + getCodexInstructions: async (model: string) => `instructions:${model}`, + createCodexHeaders: () => new Headers(), + parseCodexQuotaSnapshot: (headers: Headers, status: number) => + status === 200 + ? { + status, + planType: "plus", + activeLimit: 2, + primary: { usedPercent: 32, windowMinutes: 300, resetAtMs: 1000 }, + secondary: { + usedPercent: 64, + windowMinutes: 10080, + resetAtMs: 2000, + }, + } + : null, + getUnsupportedCodexModelInfo: (_errorBody: unknown) => ({ + isUnsupported: true, + message: "unsupported", + }), + }); + + expect(snapshot.model).toBe("gpt-5.3-codex"); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); +}); From 6192cc16dc97cbb6bcec5a497617d11ff332aa70 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:36:27 +0800 Subject: [PATCH 321/376] Fix response continuation session affinity race --- index.ts | 72 ++++----- lib/session-affinity.ts | 42 +++++- test/index.test.ts | 265 ++++++++++++++++++++++++++++++---- test/plugin-config.test.ts | 9 ++ test/session-affinity.test.ts | 10 ++ 5 files changed, 328 insertions(+), 70 deletions(-) diff --git a/index.ts b/index.ts index 98d56bdd..235d9149 100644 --- a/index.ts +++ b/index.ts @@ -978,7 +978,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const ensureSessionAffinity = ( pluginConfig: ReturnType, ): void => { - if (!getSessionAffinity(pluginConfig)) { + if ( + !getSessionAffinity(pluginConfig) && + !getResponseContinuation(pluginConfig) + ) { sessionAffinityStore = null; sessionAffinityConfigKey = null; return; @@ -1338,27 +1341,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const isStreaming = originalBody.stream === true; const parsedBody = Object.keys(originalBody).length > 0 ? { ...originalBody } : undefined; - const requestPromptCacheKey = - typeof parsedBody?.prompt_cache_key === "string" - ? parsedBody.prompt_cache_key.trim() - : ""; - const requestThreadId = - (process.env.CODEX_THREAD_ID ?? requestPromptCacheKey ?? "") - .toString() - .trim() || undefined; - const continuationSessionKey = requestThreadId ?? requestPromptCacheKey ?? null; - const shouldUseResponseContinuation = - Boolean(parsedBody) && - getResponseContinuation(pluginConfig) && - !parsedBody?.previous_response_id; - if (shouldUseResponseContinuation) { - const lastResponseId = - sessionAffinityStore?.getLastResponseId(continuationSessionKey); - if (lastResponseId && parsedBody) { - parsedBody.previous_response_id = lastResponseId; - } - } - const transformation = await transformRequestForCodex( baseInit, url, @@ -1377,6 +1359,8 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { let model = transformedBody?.model; let modelFamily = model ? getModelFamily(model) : "gpt-5.1"; let quotaKey = model ? `${modelFamily}:${model}` : modelFamily; + const responseContinuationEnabled = + getResponseContinuation(pluginConfig); const threadIdCandidate = (process.env.CODEX_THREAD_ID ?? promptCacheKey ?? "") .toString() @@ -1384,6 +1368,24 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const sessionAffinityKey = threadIdCandidate ?? promptCacheKey ?? null; const effectivePromptCacheKey = (sessionAffinityKey ?? promptCacheKey ?? "").toString().trim() || undefined; + const shouldUseResponseContinuation = + Boolean(transformedBody) && + responseContinuationEnabled && + !transformedBody?.previous_response_id; + if (shouldUseResponseContinuation && transformedBody) { + const lastResponseId = + sessionAffinityStore?.getLastResponseId(sessionAffinityKey); + if (lastResponseId) { + transformedBody = { + ...transformedBody, + previous_response_id: lastResponseId, + }; + requestInit = { + ...requestInit, + body: JSON.stringify(transformedBody), + }; + } + } const preferredSessionAccountIndex = sessionAffinityStore?.getPreferredAccountIndex( sessionAffinityKey, ); @@ -2418,7 +2420,10 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { successAccountForResponse = fallbackAccount; successEntitlementAccountKey = fallbackEntitlementAccountKey; runtimeMetrics.streamFailoverRecoveries += 1; - if (fallbackAccount.index !== account.index) { + if ( + fallbackAccount.index !== account.index && + !responseContinuationEnabled + ) { runtimeMetrics.streamFailoverCrossAccountRecoveries += 1; runtimeMetrics.accountRotations += 1; sessionAffinityStore?.remember( @@ -2468,14 +2473,16 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { }, ); } - let capturedResponseId: string | null = null; + let storedResponseIdForSuccess = false; const successResponse = await handleSuccessResponse(responseForSuccess, isStreaming, { onResponseId: (responseId) => { - capturedResponseId = responseId; - sessionAffinityStore?.rememberLastResponseId( + if (!responseContinuationEnabled) return; + sessionAffinityStore?.rememberWithResponseId( sessionAffinityKey, + successAccountForResponse.index, responseId, ); + storedResponseIdForSuccess = true; }, streamStallTimeoutMs, }); @@ -2541,14 +2548,13 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { capabilityModelKey, ); entitlementCache.clear(successAccountKey, capabilityModelKey); - sessionAffinityStore?.remember( - sessionAffinityKey, - successAccountForResponse.index, - ); - if (capturedResponseId) { - sessionAffinityStore?.rememberLastResponseId( + if ( + !responseContinuationEnabled || + (!isStreaming && !storedResponseIdForSuccess) + ) { + sessionAffinityStore?.remember( sessionAffinityKey, - capturedResponseId, + successAccountForResponse.index, ); } runtimeMetrics.successfulRequests++; diff --git a/lib/session-affinity.ts b/lib/session-affinity.ts index e907e776..60510a9b 100644 --- a/lib/session-affinity.ts +++ b/lib/session-affinity.ts @@ -68,12 +68,7 @@ export class SessionAffinityStore { const existingEntry = this.entries.get(key); - if (this.entries.size >= this.maxEntries && !this.entries.has(key)) { - const oldest = this.findOldestKey(); - if (oldest) this.entries.delete(oldest); - } - - this.entries.set(key, { + this.setEntry(key, { accountIndex, expiresAt: now + this.ttlMs, lastResponseId: existingEntry?.lastResponseId, @@ -113,7 +108,7 @@ export class SessionAffinityStore { return; } - this.entries.set(key, { + this.setEntry(key, { ...entry, expiresAt: now + this.ttlMs, lastResponseId: normalizedResponseId, @@ -121,6 +116,30 @@ export class SessionAffinityStore { }); } + rememberWithResponseId( + sessionKey: string | null | undefined, + accountIndex: number, + responseId: string | null | undefined, + now = Date.now(), + ): void { + const key = normalizeSessionKey(sessionKey); + const normalizedResponseId = typeof responseId === "string" ? responseId.trim() : ""; + if (!key || !normalizedResponseId) return; + if (!Number.isFinite(accountIndex) || accountIndex < 0) return; + + const entry = this.entries.get(key); + if (entry?.expiresAt !== undefined && entry.expiresAt <= now) { + this.entries.delete(key); + } + + this.setEntry(key, { + accountIndex, + expiresAt: now + this.ttlMs, + lastResponseId: normalizedResponseId, + updatedAt: now, + }); + } + forgetSession(sessionKey: string | null | undefined): void { const key = normalizeSessionKey(sessionKey); if (!key) return; @@ -172,6 +191,15 @@ export class SessionAffinityStore { return this.entries.size; } + private setEntry(key: string, entry: SessionAffinityEntry): void { + if (this.entries.size >= this.maxEntries && !this.entries.has(key)) { + const oldest = this.findOldestKey(); + if (oldest) this.entries.delete(oldest); + } + + this.entries.set(key, entry); + } + private findOldestKey(): string | null { let oldestKey: string | null = null; let oldestTimestamp = Number.POSITIVE_INFINITY; diff --git a/test/index.test.ts b/test/index.test.ts index fb89f4e6..954e6621 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1301,6 +1301,22 @@ describe("OpenAIOAuthPlugin fetch handler", () => { }; }; + const buildStableRoutingManager = ( + account: { + index: number; + accountId?: string; + accountIdSource?: string; + email?: string; + refreshToken: string; + accessToken?: string; + idToken?: string; + }, + ) => ({ + ...buildRoutingManager([account]), + getCurrentOrNextForFamilyHybrid: () => account, + getCurrentOrNextForFamily: () => account, + }); + it("returns success response for successful fetch", async () => { globalThis.fetch = vi.fn().mockResolvedValue( new Response(JSON.stringify({ content: "test" }), { status: 200 }), @@ -1320,27 +1336,42 @@ describe("OpenAIOAuthPlugin fetch handler", () => { const { AccountManager } = await import("../lib/accounts.js"); const configModule = await import("../lib/config.js"); const fetchHelpers = await import("../lib/request/fetch-helpers.js"); + const stableAccount = { + index: 0, + accountId: "acc-1", + email: "user@example.com", + refreshToken: "refresh-1", + }; vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValueOnce( - buildRoutingManager([ - { - index: 0, - accountId: "acc-1", - email: "user@example.com", - refreshToken: "refresh-1", - }, - ]) as never, + buildStableRoutingManager(stableAccount) as never, ); vi.mocked(configModule.getSessionAffinity).mockReturnValue(true); vi.mocked(configModule.getResponseContinuation).mockReturnValue(true); vi.mocked(fetchHelpers.transformRequestForCodex).mockImplementation( - async (init, _url, _userConfig, _codexMode, body) => ({ + async (init, _url, _userConfig, _codexMode, body) => { + const normalizedPromptCacheKey = + typeof body?.prompt_cache_key === "string" + ? `${body.prompt_cache_key.trim()}:normalized` + : undefined; + return { updatedInit: { ...(init as RequestInit), - body: JSON.stringify(body ?? {}), + body: JSON.stringify({ + ...(body ?? {}), + ...(normalizedPromptCacheKey + ? { prompt_cache_key: normalizedPromptCacheKey } + : {}), + }), }, - body: (body ?? { model: "gpt-5.4" }) as { model: string }, - }), + body: { + ...(body ?? { model: "gpt-5.4" }), + ...(normalizedPromptCacheKey + ? { prompt_cache_key: normalizedPromptCacheKey } + : {}), + } as { model: string; prompt_cache_key?: string; previous_response_id?: string }, + }; + }, ); vi.mocked(fetchHelpers.handleSuccessResponse) .mockImplementationOnce(async (response, _isStreaming, options) => { @@ -1351,7 +1382,9 @@ describe("OpenAIOAuthPlugin fetch handler", () => { globalThis.fetch = vi .fn() - .mockResolvedValue(new Response(JSON.stringify({ content: "ok" }), { status: 200 })); + .mockImplementation( + async () => new Response(JSON.stringify({ content: "ok" }), { status: 200 }), + ); const { sdk } = await setupPlugin(); await sdk.fetch!("https://api.openai.com/v1/chat", { @@ -1363,31 +1396,35 @@ describe("OpenAIOAuthPlugin fetch handler", () => { body: JSON.stringify({ model: "gpt-5.4", prompt_cache_key: "ses_contract" }), }); - const firstBody = vi.mocked(fetchHelpers.transformRequestForCodex).mock.calls[0]?.[4] as - | { previous_response_id?: string } - | undefined; - const secondBody = vi.mocked(fetchHelpers.transformRequestForCodex).mock.calls[1]?.[4] as - | { previous_response_id?: string } - | undefined; + const firstInit = vi.mocked(globalThis.fetch).mock.calls[0]?.[1] as RequestInit; + const secondInit = vi.mocked(globalThis.fetch).mock.calls[1]?.[1] as RequestInit; + const firstBody = JSON.parse(String(firstInit.body)) as { + previous_response_id?: string; + prompt_cache_key?: string; + }; + const secondBody = JSON.parse(String(secondInit.body)) as { + previous_response_id?: string; + prompt_cache_key?: string; + }; expect(firstBody?.previous_response_id).toBeUndefined(); expect(secondBody?.previous_response_id).toBe("resp_prev_123"); + expect(secondBody?.prompt_cache_key).toBe("ses_contract:normalized"); }); it("preserves explicit previous_response_id over stored continuation state", async () => { const { AccountManager } = await import("../lib/accounts.js"); const configModule = await import("../lib/config.js"); const fetchHelpers = await import("../lib/request/fetch-helpers.js"); + const stableAccount = { + index: 0, + accountId: "acc-1", + email: "user@example.com", + refreshToken: "refresh-1", + }; vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValueOnce( - buildRoutingManager([ - { - index: 0, - accountId: "acc-1", - email: "user@example.com", - refreshToken: "refresh-1", - }, - ]) as never, + buildStableRoutingManager(stableAccount) as never, ); vi.mocked(configModule.getSessionAffinity).mockReturnValue(true); vi.mocked(configModule.getResponseContinuation).mockReturnValue(true); @@ -1425,12 +1462,180 @@ describe("OpenAIOAuthPlugin fetch handler", () => { }), }); - const secondBody = vi.mocked(fetchHelpers.transformRequestForCodex).mock.calls[1]?.[4] as - | { previous_response_id?: string } - | undefined; + const secondInit = vi.mocked(globalThis.fetch).mock.calls[1]?.[1] as RequestInit; + const secondBody = JSON.parse(String(secondInit.body)) as { + previous_response_id?: string; + }; expect(secondBody?.previous_response_id).toBe("resp_explicit_456"); }); + it("injects stored previous_response_id when continuation is enabled without session affinity", async () => { + const { AccountManager } = await import("../lib/accounts.js"); + const configModule = await import("../lib/config.js"); + const fetchHelpers = await import("../lib/request/fetch-helpers.js"); + const stableAccount = { + index: 0, + accountId: "acc-1", + email: "user@example.com", + refreshToken: "refresh-1", + }; + + vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValueOnce( + buildStableRoutingManager(stableAccount) as never, + ); + vi.mocked(configModule.getSessionAffinity).mockReturnValue(false); + vi.mocked(configModule.getResponseContinuation).mockReturnValue(true); + vi.mocked(fetchHelpers.transformRequestForCodex).mockImplementation( + async (init, _url, _userConfig, _codexMode, body) => ({ + updatedInit: { + ...(init as RequestInit), + body: JSON.stringify(body ?? {}), + }, + body: (body ?? { model: "gpt-5.4" }) as { + model: string; + previous_response_id?: string; + }, + }), + ); + vi.mocked(fetchHelpers.handleSuccessResponse) + .mockImplementationOnce(async (response, _isStreaming, options) => { + options?.onResponseId?.("resp_standalone_789"); + return response; + }) + .mockImplementationOnce(async (response) => response); + + globalThis.fetch = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ content: "ok" }), { status: 200 })); + + const { sdk } = await setupPlugin(); + await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.4", prompt_cache_key: "ses_contract" }), + }); + await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.4", prompt_cache_key: "ses_contract" }), + }); + + const secondInit = vi.mocked(globalThis.fetch).mock.calls[1]?.[1] as RequestInit; + const secondBody = JSON.parse(String(secondInit.body)) as { + previous_response_id?: string; + }; + expect(secondBody?.previous_response_id).toBe("resp_standalone_789"); + }); + + it("keeps account and previous_response_id aligned across overlapping same-session streams", async () => { + const { AccountManager } = await import("../lib/accounts.js"); + const configModule = await import("../lib/config.js"); + const fetchHelpers = await import("../lib/request/fetch-helpers.js"); + const streamFailoverModule = await import("../lib/request/stream-failover.js"); + const accounts = [ + { + index: 0, + accountId: "acc-1", + email: "alpha@example.com", + refreshToken: "refresh-1", + accessToken: "access-alpha", + }, + { + index: 1, + accountId: "acc-2", + email: "beta@example.com", + refreshToken: "refresh-2", + accessToken: "access-beta", + }, + ]; + let selection = 0; + const manager = { + ...buildRoutingManager(accounts), + getCurrentOrNextForFamilyHybrid: () => accounts[selection++] ?? accounts[1] ?? null, + getCurrentOrNextForFamily: () => accounts[selection++] ?? accounts[1] ?? null, + }; + const responseIdCallbacks: Array<(responseId: string) => void> = []; + + vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValueOnce(manager as never); + vi.mocked(configModule.getSessionAffinity).mockReturnValue(true); + vi.mocked(configModule.getResponseContinuation).mockReturnValue(true); + extractAccountIdMock.mockImplementation((accessToken: unknown) => { + if (accessToken === "access-alpha") return "acc-1"; + if (accessToken === "access-beta") return "acc-2"; + return "account-1"; + }); + vi.spyOn(streamFailoverModule, "withStreamingFailover").mockImplementation( + (initialResponse) => initialResponse, + ); + vi.mocked(fetchHelpers.createCodexHeaders).mockImplementation( + (_init, accountId, accessToken) => + new Headers({ + "x-test-account-id": String(accountId), + "x-test-access-token": String(accessToken), + }), + ); + vi.mocked(fetchHelpers.transformRequestForCodex).mockImplementation( + async (init, _url, _userConfig, _codexMode, body) => ({ + updatedInit: { + ...(init as RequestInit), + body: JSON.stringify(body ?? {}), + }, + body: (body ?? { model: "gpt-5.4" }) as { + model: string; + prompt_cache_key?: string; + previous_response_id?: string; + }, + }), + ); + vi.mocked(fetchHelpers.handleSuccessResponse).mockImplementation( + async (response, _isStreaming, options) => { + if (options?.onResponseId) { + responseIdCallbacks.push(options.onResponseId); + } + return response; + }, + ); + + globalThis.fetch = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ content: "ok" }), { status: 200 })); + + const { sdk } = await setupPlugin(); + const firstRequest = sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ + model: "gpt-5.4", + prompt_cache_key: "ses_contract", + stream: true, + }), + }); + const secondRequest = sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ + model: "gpt-5.4", + prompt_cache_key: "ses_contract", + stream: true, + }), + }); + + await Promise.all([firstRequest, secondRequest]); + expect(responseIdCallbacks).toHaveLength(2); + responseIdCallbacks[1]?.("resp_second_456"); + responseIdCallbacks[0]?.("resp_first_123"); + + await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.4", prompt_cache_key: "ses_contract" }), + }); + + const thirdInit = vi.mocked(globalThis.fetch).mock.calls[2]?.[1] as RequestInit; + const thirdBody = JSON.parse(String(thirdInit.body)) as { + previous_response_id?: string; + }; + const thirdHeaders = new Headers(thirdInit.headers); + expect(thirdBody.previous_response_id).toBe("resp_first_123"); + expect(thirdHeaders.get("x-test-account-id")).toBe("acc-1"); + expect(thirdHeaders.get("x-test-access-token")).toBe("access-alpha"); + }); + it("uses the refreshed token email when checking entitlement blocks", async () => { const { AccountManager } = await import("../lib/accounts.js"); const manager = buildRoutingManager([ diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index ed3bb8fa..e34e650f 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -204,6 +204,15 @@ describe('Plugin Configuration', () => { }); }); + it('loads responseContinuation from disk config', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + JSON.stringify({ responseContinuation: true }), + ); + + expect(loadPluginConfig().responseContinuation).toBe(true); + }); + it('should detect CODEX_HOME legacy auth config path before global legacy path', async () => { const runWithCodexHome = async (codexHomePath: string) => { vi.resetModules(); diff --git a/test/session-affinity.test.ts b/test/session-affinity.test.ts index 5fa57b51..e9b09d97 100644 --- a/test/session-affinity.test.ts +++ b/test/session-affinity.test.ts @@ -133,4 +133,14 @@ describe("SessionAffinityStore", () => { expect(store.getLastResponseId("session-a", 2_500)).toBeNull(); expect(store.size()).toBe(0); }); + + it("preserves response id when account index is updated via remember()", () => { + const store = new SessionAffinityStore({ ttlMs: 10_000, maxEntries: 4 }); + store.remember("session-a", 1, 1_000); + store.rememberLastResponseId("session-a", "resp_123", 2_000); + store.remember("session-a", 2, 3_000); + + expect(store.getLastResponseId("session-a", 3_500)).toBe("resp_123"); + expect(store.getPreferredAccountIndex("session-a", 3_500)).toBe(2); + }); }); From 3b415feba0514fa8a31c07b6fbaa26bc034a930b Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:46:16 +0800 Subject: [PATCH 322/376] test: expand verify-flagged command coverage --- ...dex-manager-verify-flagged-command.test.ts | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/test/codex-manager-verify-flagged-command.test.ts b/test/codex-manager-verify-flagged-command.test.ts index a46795aa..3af1f9ee 100644 --- a/test/codex-manager-verify-flagged-command.test.ts +++ b/test/codex-manager-verify-flagged-command.test.ts @@ -188,5 +188,143 @@ describe("runVerifyFlaggedCommand", () => { ); expect(payload.remainingFlagged).toBe(1); expect(payload.reports).toHaveLength(2); + expect(deps.saveFlaggedAccounts).not.toHaveBeenCalled(); + }); + + it("keeps healthy accounts flagged when --no-restore is selected", async () => { + const deps = createDeps({ + parseVerifyFlaggedArgs: vi.fn(() => ({ + ok: true as const, + options: { + dryRun: false, + json: true, + restore: false, + } satisfies VerifyFlaggedCliOptions, + })), + queuedRefresh: vi.fn(async () => ({ + type: "success", + access: "healthy-access", + refresh: "healthy-refresh", + expires: 5_000, + })), + resolveStoredAccountIdentity: vi.fn(() => ({ + accountId: "acct_healthy", + accountIdSource: "jwt", + })), + extractAccountId: vi.fn(() => "acct_healthy"), + extractAccountEmail: vi.fn(() => "healthy@example.com"), + }); + + const result = await runVerifyFlaggedCommand([], deps); + const payload = JSON.parse( + (deps.logInfo as ReturnType).mock.calls.at(-1)![0], + ); + + expect(result).toBe(0); + expect(payload.healthyFlagged).toBe(1); + expect(payload.remainingFlagged).toBe(1); + expect(payload.reports[0]).toEqual( + expect.objectContaining({ + outcome: "healthy-flagged", + }), + ); + expect(deps.withAccountAndFlaggedStorageTransaction).not.toHaveBeenCalled(); + expect(deps.saveFlaggedAccounts).toHaveBeenCalledTimes(1); + }); + + it("does not persist storage changes during dry-run restore", async () => { + const deps = createDeps({ + parseVerifyFlaggedArgs: vi.fn(() => ({ + ok: true as const, + options: { + dryRun: true, + json: true, + restore: true, + } satisfies VerifyFlaggedCliOptions, + })), + queuedRefresh: vi.fn(async () => ({ + type: "success", + access: "restored-access", + refresh: "restored-refresh", + expires: 5_000, + })), + upsertRecoveredFlaggedAccount: vi.fn(() => ({ + restored: true, + changed: true, + message: "restored", + })), + }); + + const result = await runVerifyFlaggedCommand([], deps); + const payload = JSON.parse( + (deps.logInfo as ReturnType).mock.calls.at(-1)![0], + ); + + expect(result).toBe(0); + expect(payload.dryRun).toBe(true); + expect(payload.restored).toBe(1); + expect(deps.withAccountAndFlaggedStorageTransaction).not.toHaveBeenCalled(); + expect(deps.saveFlaggedAccounts).not.toHaveBeenCalled(); + }); + + it("prints a human summary for non-json verification output", async () => { + const deps = createDeps({ + parseVerifyFlaggedArgs: vi.fn(() => ({ + ok: true as const, + options: { + dryRun: false, + json: false, + restore: false, + } satisfies VerifyFlaggedCliOptions, + })), + queuedRefresh: vi.fn(async () => ({ + type: "failed", + reason: "invalid_grant", + message: "token expired", + })), + stylePromptText: vi.fn((text) => `styled:${text}`), + styleAccountDetailText: vi.fn((text) => `detail:${text}`), + formatResultSummary: vi.fn(() => "summary:0 restored"), + }); + + const result = await runVerifyFlaggedCommand([], deps); + + expect(result).toBe(0); + expect(deps.stylePromptText).toHaveBeenCalledWith( + "Checking 1 flagged account(s)...", + "accent", + ); + expect(deps.formatResultSummary).toHaveBeenCalledWith([ + { text: "0 restored", tone: "muted" }, + { text: "0 healthy (kept flagged)", tone: "muted" }, + { text: "1 still flagged", tone: "danger" }, + ]); + expect(deps.logInfo).toHaveBeenCalledWith("summary:0 restored"); + expect(deps.logInfo).toHaveBeenCalledWith( + expect.stringContaining("detail:token expired"), + ); + }); + + it("returns early when no flagged accounts are stored", async () => { + const deps = createDeps({ + parseVerifyFlaggedArgs: vi.fn(() => ({ + ok: true as const, + options: { + dryRun: false, + json: false, + restore: true, + } satisfies VerifyFlaggedCliOptions, + })), + loadFlaggedAccounts: vi.fn(async () => ({ + version: 1 as const, + accounts: [], + })), + }); + + const result = await runVerifyFlaggedCommand([], deps); + + expect(result).toBe(0); + expect(deps.logInfo).toHaveBeenCalledWith("No flagged accounts to check."); + expect(deps.queuedRefresh).not.toHaveBeenCalled(); }); }); From a0e3841a540285a27cfa01b9e417460e48759a94 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:48:25 +0800 Subject: [PATCH 323/376] add prompt cache retention regressions --- test/public-api-contract.test.ts | 4 ++++ test/request-transformer.test.ts | 37 ++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/test/public-api-contract.test.ts b/test/public-api-contract.test.ts index 89aa891a..a9d9a484 100644 --- a/test/public-api-contract.test.ts +++ b/test/public-api-contract.test.ts @@ -141,5 +141,9 @@ describe("public api contract", () => { codexInstructions: "codex", }); expect(transformedNamed).toEqual(transformedPositional); + expect(transformedPositional.prompt_cache_retention).toBe(baseBody.prompt_cache_retention); + expect(transformedNamed.prompt_cache_retention).toBe(baseBody.prompt_cache_retention); + expect(transformedPositional.text?.format).toEqual(baseBody.text?.format); + expect(transformedNamed.text?.format).toEqual(baseBody.text?.format); }); }); diff --git a/test/request-transformer.test.ts b/test/request-transformer.test.ts index 98dc4d2f..af8d5c84 100644 --- a/test/request-transformer.test.ts +++ b/test/request-transformer.test.ts @@ -1314,6 +1314,43 @@ describe('Request Transformer Module', () => { expect(result.prompt_cache_retention).toBe('7d'); }); + it('should inherit prompt_cache_retention from model-specific user config', async () => { + const body: RequestBody = { + model: 'gpt-5.4', + input: [], + }; + const userConfig: UserConfig = { + global: { promptCacheRetention: '7d' }, + models: { + 'gpt-5.4': { + options: { promptCacheRetention: '24h' }, + }, + }, + }; + const result = await transformRequestBody(body, codexInstructions, userConfig); + expect(result.prompt_cache_retention).toBe('24h'); + }); + + it('should inherit model-specific prompt_cache_retention in named params overload', async () => { + const userConfig: UserConfig = { + global: { promptCacheRetention: '7d' }, + models: { + 'gpt-5.4': { + options: { promptCacheRetention: '24h' }, + }, + }, + }; + const result = await transformRequestBody({ + body: { + model: 'gpt-5.4', + input: [], + }, + codexInstructions, + userConfig, + }); + expect(result.prompt_cache_retention).toBe('24h'); + }); + it('should prefer body text verbosity over providerOptions', async () => { const body: RequestBody = { model: 'gpt-5', From 7c1507da0f17e4f36edaab2ad0cb4171958a2e8a Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:53:41 +0800 Subject: [PATCH 324/376] refactor: share verify-flagged command deps --- lib/codex-manager.ts | 72 +++++++++++++--------------------- test/codex-manager-cli.test.ts | 51 ++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 44 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 6a62ff17..5fc33786 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -46,6 +46,7 @@ import { runSwitchCommand } from "./codex-manager/commands/switch.js"; import { runVerifyFlaggedCommand, type VerifyFlaggedCliOptions, + type VerifyFlaggedCommandDeps, } from "./codex-manager/commands/verify-flagged.js"; import { applyUiThemeFromDashboardSettings, @@ -3808,6 +3809,31 @@ async function handleManageAction( } } +function buildVerifyFlaggedCommandDeps(): VerifyFlaggedCommandDeps { + return { + setStoragePath, + loadFlaggedAccounts, + loadAccounts, + queuedRefresh, + parseVerifyFlaggedArgs, + printVerifyFlaggedUsage, + createEmptyAccountStorage, + upsertRecoveredFlaggedAccount, + resolveStoredAccountIdentity, + extractAccountId, + extractAccountEmail, + sanitizeEmail, + normalizeFailureDetail, + withAccountAndFlaggedStorageTransaction, + normalizeDoctorIndexes, + saveFlaggedAccounts, + formatAccountLabel, + stylePromptText, + styleAccountDetailText, + formatResultSummary, + }; +} + async function runAuthLogin(args: string[]): Promise { const parsedArgs = parseAuthLoginArgs(args); if (!parsedArgs.ok) { @@ -3936,28 +3962,7 @@ async function runAuthLogin(args: string[]): Promise { "Problem Account Check", "Checking problem accounts", async () => { - await runVerifyFlaggedCommand([], { - setStoragePath, - loadFlaggedAccounts, - loadAccounts, - queuedRefresh, - parseVerifyFlaggedArgs, - printVerifyFlaggedUsage, - createEmptyAccountStorage, - upsertRecoveredFlaggedAccount, - resolveStoredAccountIdentity, - extractAccountId, - extractAccountEmail, - sanitizeEmail, - normalizeFailureDetail, - withAccountAndFlaggedStorageTransaction, - normalizeDoctorIndexes, - saveFlaggedAccounts, - formatAccountLabel, - stylePromptText, - styleAccountDetailText, - formatResultSummary, - }); + await runVerifyFlaggedCommand([], buildVerifyFlaggedCommandDeps()); }, displaySettings, ); @@ -4678,28 +4683,7 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { return runFeaturesCommand({ implementedFeatures: IMPLEMENTED_FEATURES }); } if (command === "verify-flagged") { - return runVerifyFlaggedCommand(rest, { - setStoragePath, - loadFlaggedAccounts, - loadAccounts, - queuedRefresh, - parseVerifyFlaggedArgs, - printVerifyFlaggedUsage, - createEmptyAccountStorage, - upsertRecoveredFlaggedAccount, - resolveStoredAccountIdentity, - extractAccountId, - extractAccountEmail, - sanitizeEmail, - normalizeFailureDetail, - withAccountAndFlaggedStorageTransaction, - normalizeDoctorIndexes, - saveFlaggedAccounts, - formatAccountLabel, - stylePromptText, - styleAccountDetailText, - formatResultSummary, - }); + return runVerifyFlaggedCommand(rest, buildVerifyFlaggedCommandDeps()); } if (command === "forecast") { return runForecast(rest); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 6a6a824d..e961179f 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -4733,6 +4733,57 @@ describe("codex manager cli commands", () => { expect(queuedRefreshMock).not.toHaveBeenCalled(); }); + it("runs verify-flagged from the login menu", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "a@example.com", + accountId: "acc_a", + refreshToken: "refresh-a", + accessToken: "access-a", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + loadFlaggedAccountsMock.mockResolvedValue({ + version: 1, + accounts: [ + { + refreshToken: "flagged-refresh", + accountId: "acc_flagged", + email: "flagged@example.com", + addedAt: now - 1_000, + lastUsed: now - 1_000, + flaggedAt: now - 5_000, + }, + ], + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "verify-flagged" }) + .mockResolvedValueOnce({ mode: "cancel" }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "failed", + reason: "invalid_grant", + message: "token expired", + }); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + expect(exitCode).toBe(0); + expect(queuedRefreshMock).toHaveBeenCalledTimes(1); + expect(withAccountAndFlaggedStorageTransactionMock).toHaveBeenCalledTimes( + 1, + ); + expect(saveFlaggedAccountsMock).toHaveBeenCalledTimes(1); + }); + it("auto-refreshes cached limits on menu open", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValue({ From 43359e7ec17d72cd0f756794e2f6723841255dc2 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:56:16 +0800 Subject: [PATCH 325/376] Abort response-id capture after SSE errors --- lib/request/response-handler.ts | 26 ++++++++++++++++++------ test/response-handler.test.ts | 36 +++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/lib/request/response-handler.ts b/lib/request/response-handler.ts index b8db279b..861be8ab 100644 --- a/lib/request/response-handler.ts +++ b/lib/request/response-handler.ts @@ -32,18 +32,25 @@ function notifyResponseId( } } +type CapturedResponseEvent = + | { kind: "error" } + | { kind: "response"; response: unknown } + | null; + function maybeCaptureResponseEvent( data: SSEEventData, onResponseId?: (responseId: string) => void, -): unknown | null { +): CapturedResponseEvent { if (data.type === "error") { log.error("SSE error event received", { error: data }); - return null; + return { kind: "error" }; } if (data.type === "response.done" || data.type === "response.completed") { notifyResponseId(onResponseId, data.response); - return data.response ?? null; + if (data.response !== undefined && data.response !== null) { + return { kind: "response", response: data.response }; + } } return null; @@ -68,8 +75,9 @@ function parseSseStream( if (!payload || payload === '[DONE]') continue; try { const data = JSON.parse(payload) as SSEEventData; - const finalResponse = maybeCaptureResponseEvent(data, onResponseId); - if (finalResponse) return finalResponse; + const capturedEvent = maybeCaptureResponseEvent(data, onResponseId); + if (capturedEvent?.kind === "error") return null; + if (capturedEvent?.kind === "response") return capturedEvent.response; } catch { // Skip malformed JSON } @@ -165,8 +173,10 @@ function createResponseIdCapturingStream( ): ReadableStream { const decoder = new TextDecoder(); let bufferedText = ""; + let sawErrorEvent = false; const processBufferedLines = (flush = false): void => { + if (sawErrorEvent) return; const lines = bufferedText.split(/\r?\n/); if (!flush) { bufferedText = lines.pop() ?? ""; @@ -181,7 +191,11 @@ function createResponseIdCapturingStream( if (!payload || payload === "[DONE]") continue; try { const data = JSON.parse(payload) as SSEEventData; - maybeCaptureResponseEvent(data, onResponseId); + const capturedEvent = maybeCaptureResponseEvent(data, onResponseId); + if (capturedEvent?.kind === "error") { + sawErrorEvent = true; + break; + } } catch { // Ignore malformed SSE lines and keep forwarding the raw stream. } diff --git a/test/response-handler.test.ts b/test/response-handler.test.ts index 880554c6..3e0fb0dd 100644 --- a/test/response-handler.test.ts +++ b/test/response-handler.test.ts @@ -129,6 +129,24 @@ data: {"type":"response.done","response":{"id":"resp_789"}} expect(onResponseId).toHaveBeenCalledWith('resp_123'); }); + it('should return the raw SSE text when an error event arrives before response.done', async () => { + const onResponseId = vi.fn(); + const sseContent = [ + 'data: {"type":"error","message":"quota exceeded"}', + '', + 'data: {"type":"response.done","response":{"id":"resp_bad_123","output":"bad"}}', + '', + ].join('\n'); + const response = new Response(sseContent); + const headers = new Headers(); + + const result = await convertSseToJson(response, headers, { onResponseId }); + const text = await result.text(); + + expect(text).toBe(sseContent); + expect(onResponseId).not.toHaveBeenCalled(); + }); + it('should throw error if SSE stream exceeds size limit', async () => { const largeContent = 'a'.repeat(20 * 1024 * 1024 + 1); const response = new Response(largeContent); @@ -202,6 +220,24 @@ data: {"type":"response.done","response":{"id":"resp_789"}} expect(onResponseId).toHaveBeenCalledWith('resp_stream_123'); expect(captured.headers.get('content-type')).toBe('text/event-stream'); }); + + it('should stop capturing response ids after an SSE error event', async () => { + const onResponseId = vi.fn(); + const sseContent = [ + 'data: {"type":"error","message":"quota exceeded"}', + '', + 'data: {"type":"response.done","response":{"id":"resp_bad_123","output":"done"}}', + '', + ].join('\n'); + const response = new Response(sseContent); + const headers = new Headers({ 'content-type': 'text/event-stream' }); + + const captured = attachResponseIdCapture(response, headers, onResponseId); + const text = await captured.text(); + + expect(text).toBe(sseContent); + expect(onResponseId).not.toHaveBeenCalled(); + }); }); describe('isEmptyResponse', () => { From 7cc6bf63d92ad09735dbcb2ca2945c211a5f6c9e Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:56:30 +0800 Subject: [PATCH 326/376] clarify responses contract docs --- docs/reference/public-api.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md index 43ca69f4..a31a12c1 100644 --- a/docs/reference/public-api.md +++ b/docs/reference/public-api.md @@ -67,9 +67,7 @@ Positional signatures are preserved for backward compatibility. The request-transform layer intentionally preserves and/or normalizes modern Responses API fields that callers may already send through the host SDK. -- `previous_response_id` is preserved when explicitly provided and may be auto-filled from plugin continuation state when `pluginConfig.responseContinuation` is enabled. -- `text.format` is preserved when verbosity defaults are applied, so structured-output contracts continue to flow through untouched. -- `prompt_cache_retention` is preserved from the request body and can fall back to `providerOptions.openai.promptCacheRetention` or user config defaults. +- The plugin preserves `previous_response_id` when explicitly provided and may auto-fill it from plugin continuation state when `pluginConfig.responseContinuation` is enabled, maintains `text.format` when verbosity defaults are applied, and honors `prompt_cache_retention` from the request body before falling back to `providerOptions.openai.promptCacheRetention` or user config defaults. - Hosted built-in tool definitions are typed and supported for: - `tool_search` - remote `mcp` @@ -83,6 +81,8 @@ The request-transform layer intentionally preserves and/or normalizes modern Res - `final_answer_text` - `phase_text` +These SSE compatibility fields are synthesized only when the corresponding content is present in the response stream. + These behaviors are compatibility guarantees for the current release line because they protect caller intent while keeping the plugin stateless against the ChatGPT Codex backend. --- From 5f0405ed3756d749f807a8f13e73740e3c4ccab8 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:00:35 +0800 Subject: [PATCH 327/376] test: cover dashboard display panel extraction --- lib/codex-manager/dashboard-display-panel.ts | 2 +- lib/codex-manager/settings-hub.ts | 28 +-- test/dashboard-display-panel.test.ts | 226 +++++++++++++++++++ 3 files changed, 233 insertions(+), 23 deletions(-) create mode 100644 test/dashboard-display-panel.test.ts diff --git a/lib/codex-manager/dashboard-display-panel.ts b/lib/codex-manager/dashboard-display-panel.ts index 06d85c5f..9c86fb81 100644 --- a/lib/codex-manager/dashboard-display-panel.ts +++ b/lib/codex-manager/dashboard-display-panel.ts @@ -47,7 +47,7 @@ export interface DashboardDisplayPanelDeps { formatDashboardSettingState: (enabled: boolean) => string; formatMenuSortMode: (mode: DashboardAccountSortMode) => string; resolveMenuLayoutMode: ( - settings?: DashboardDisplaySettings, + settings: DashboardDisplaySettings, ) => "compact-details" | "expanded-rows"; formatMenuLayoutMode: (mode: "compact-details" | "expanded-rows") => string; applyDashboardDefaultsForKeys: ( diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index c122d515..d815ab8d 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -32,28 +32,13 @@ import { type MenuItem, type SelectOptions, select } from "../ui/select.js"; import { getUnifiedSettingsPath } from "../unified-settings.js"; import { sleep } from "../utils.js"; import { promptBehaviorSettingsPanel } from "./behavior-settings-panel.js"; -import { promptDashboardDisplayPanel } from "./dashboard-display-panel.js"; +import { + promptDashboardDisplayPanel, + type DashboardDisplaySettingKey, + type DashboardDisplaySettingOption, +} from "./dashboard-display-panel.js"; import { promptThemeSettingsPanel } from "./theme-settings-panel.js"; -type DashboardDisplaySettingKey = - | "menuShowStatusBadge" - | "menuShowCurrentBadge" - | "menuShowLastUsed" - | "menuShowQuotaSummary" - | "menuShowQuotaCooldown" - | "menuShowDetailsForUnselectedRows" - | "menuShowFetchStatus" - | "menuHighlightCurrentRow" - | "menuSortEnabled" - | "menuSortPinCurrent" - | "menuSortQuickSwitchVisibleRow"; - -interface DashboardDisplaySettingOption { - key: DashboardDisplaySettingKey; - label: string; - description: string; -} - const DASHBOARD_DISPLAY_OPTIONS: DashboardDisplaySettingOption[] = [ { key: "menuShowStatusBadge", @@ -1318,8 +1303,7 @@ async function promptDashboardDisplaySettings( buildAccountListPreview, formatDashboardSettingState, formatMenuSortMode, - resolveMenuLayoutMode: (settings) => - resolveMenuLayoutMode(settings ?? DEFAULT_DASHBOARD_DISPLAY_SETTINGS), + resolveMenuLayoutMode, formatMenuLayoutMode, applyDashboardDefaultsForKeys, DASHBOARD_DISPLAY_OPTIONS, diff --git a/test/dashboard-display-panel.test.ts b/test/dashboard-display-panel.test.ts new file mode 100644 index 00000000..b890b507 --- /dev/null +++ b/test/dashboard-display-panel.test.ts @@ -0,0 +1,226 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + type DashboardDisplaySettings, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS, +} from "../lib/dashboard-settings.js"; +import { UI_COPY } from "../lib/ui/copy.js"; +import { + type DashboardDisplayPanelDeps, + promptDashboardDisplayPanel, +} from "../lib/codex-manager/dashboard-display-panel.js"; + +const { selectMock, getUiRuntimeOptionsMock } = vi.hoisted(() => ({ + selectMock: vi.fn(), + getUiRuntimeOptionsMock: vi.fn(() => ({ theme: "test-theme" })), +})); + +vi.mock("../lib/ui/select.js", () => ({ + select: selectMock, +})); + +vi.mock("../lib/ui/runtime.js", () => ({ + getUiRuntimeOptions: getUiRuntimeOptionsMock, +})); + +const stdinIsTTYDescriptor = Object.getOwnPropertyDescriptor( + process.stdin, + "isTTY", +); +const stdoutIsTTYDescriptor = Object.getOwnPropertyDescriptor( + process.stdout, + "isTTY", +); + +const DASHBOARD_DISPLAY_OPTIONS: DashboardDisplayPanelDeps["DASHBOARD_DISPLAY_OPTIONS"] = + [ + { + key: "menuShowStatusBadge", + label: "Show Status Badges", + description: "Show [ok], [active], and similar badges.", + }, + { + key: "menuShowCurrentBadge", + label: "Show [current]", + description: "Mark the account active in Codex.", + }, + { + key: "menuShowFetchStatus", + label: "Show Fetch Status", + description: "Show background limit refresh status in the menu subtitle.", + }, + ]; + +const ACCOUNT_LIST_PANEL_KEYS: DashboardDisplayPanelDeps["ACCOUNT_LIST_PANEL_KEYS"] = [ + "menuShowStatusBadge", + "menuShowCurrentBadge", + "menuShowFetchStatus", + "menuSortMode", + "menuSortEnabled", + "menuLayoutMode", + "menuShowDetailsForUnselectedRows", +]; + +function setInteractiveTTY(enabled: boolean): void { + Object.defineProperty(process.stdin, "isTTY", { + value: enabled, + configurable: true, + }); + Object.defineProperty(process.stdout, "isTTY", { + value: enabled, + configurable: true, + }); +} + +function restoreTTYDescriptors(): void { + if (stdinIsTTYDescriptor) { + Object.defineProperty(process.stdin, "isTTY", stdinIsTTYDescriptor); + } else { + delete (process.stdin as unknown as { isTTY?: boolean }).isTTY; + } + + if (stdoutIsTTYDescriptor) { + Object.defineProperty(process.stdout, "isTTY", stdoutIsTTYDescriptor); + } else { + delete (process.stdout as unknown as { isTTY?: boolean }).isTTY; + } +} + +function createSettings( + overrides: Partial = {}, +): DashboardDisplaySettings { + return { + ...DEFAULT_DASHBOARD_DISPLAY_SETTINGS, + ...overrides, + }; +} + +function buildDeps(): DashboardDisplayPanelDeps { + return { + cloneDashboardSettings: (settings) => ({ ...settings }), + buildAccountListPreview: (_settings, _ui, focusKey) => ({ + label: `Preview: ${focusKey}`, + hint: "Preview hint", + }), + formatDashboardSettingState: (enabled) => (enabled ? "[x]" : "[ ]"), + formatMenuSortMode: (mode) => + mode === "ready-first" ? "Ready-First" : "Manual", + resolveMenuLayoutMode: (settings) => { + if (settings.menuLayoutMode === "expanded-rows") { + return "expanded-rows"; + } + if (settings.menuLayoutMode === "compact-details") { + return "compact-details"; + } + return settings.menuShowDetailsForUnselectedRows === true + ? "expanded-rows" + : "compact-details"; + }, + formatMenuLayoutMode: (mode) => + mode === "expanded-rows" + ? "Expanded Rows" + : "Compact + Details Pane", + applyDashboardDefaultsForKeys: (draft, keys) => { + const next = { ...draft }; + for (const key of keys) { + next[key] = DEFAULT_DASHBOARD_DISPLAY_SETTINGS[key]; + } + return next; + }, + DASHBOARD_DISPLAY_OPTIONS, + ACCOUNT_LIST_PANEL_KEYS, + UI_COPY, + }; +} + +describe("promptDashboardDisplayPanel", () => { + beforeEach(() => { + selectMock.mockReset(); + getUiRuntimeOptionsMock.mockClear(); + setInteractiveTTY(true); + }); + + afterEach(() => { + restoreTTYDescriptors(); + }); + + it("returns null without TTY access", async () => { + setInteractiveTTY(false); + + const result = await promptDashboardDisplayPanel(createSettings(), buildDeps()); + + expect(result).toBeNull(); + expect(selectMock).not.toHaveBeenCalled(); + }); + + it("toggles a display flag and saves the draft", async () => { + selectMock + .mockResolvedValueOnce({ + type: "toggle", + key: "menuShowStatusBadge", + }) + .mockResolvedValueOnce({ type: "save" }); + + const result = await promptDashboardDisplayPanel(createSettings(), buildDeps()); + + expect(result?.menuShowStatusBadge).toBe(false); + expect(selectMock).toHaveBeenCalledTimes(2); + }); + + it("resets changed values back to dashboard defaults", async () => { + selectMock + .mockResolvedValueOnce({ + type: "toggle", + key: "menuShowStatusBadge", + }) + .mockResolvedValueOnce({ type: "reset" }) + .mockResolvedValueOnce({ type: "save" }); + + const result = await promptDashboardDisplayPanel(createSettings(), buildDeps()); + + expect(result?.menuShowStatusBadge).toBe( + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuShowStatusBadge, + ); + }); + + it("cycles the sort mode and re-enables smart sort when switching to ready-first", async () => { + selectMock + .mockResolvedValueOnce({ type: "cycle-sort-mode" }) + .mockResolvedValueOnce({ type: "save" }); + + const result = await promptDashboardDisplayPanel( + createSettings({ + menuSortMode: "manual", + menuSortEnabled: false, + }), + buildDeps(), + ); + + expect(result?.menuSortMode).toBe("ready-first"); + expect(result?.menuSortEnabled).toBe(true); + }); + + it("cycles the layout mode and syncs the details-pane flag", async () => { + selectMock + .mockResolvedValueOnce({ type: "cycle-layout-mode" }) + .mockResolvedValueOnce({ type: "save" }); + + const result = await promptDashboardDisplayPanel( + createSettings({ + menuLayoutMode: "compact-details", + menuShowDetailsForUnselectedRows: false, + }), + buildDeps(), + ); + + expect(result?.menuLayoutMode).toBe("expanded-rows"); + expect(result?.menuShowDetailsForUnselectedRows).toBe(true); + }); + + it("returns null when the panel is cancelled", async () => { + selectMock.mockResolvedValueOnce({ type: "cancel" }); + + const result = await promptDashboardDisplayPanel(createSettings(), buildDeps()); + + expect(result).toBeNull(); + }); +}); From b1914927f910f649f50505d9f7516deb9f8d3c7e Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:07:09 +0800 Subject: [PATCH 328/376] test: cover storage file path helpers --- lib/storage.ts | 3 +- lib/storage/file-paths.ts | 14 +++------ test/storage-file-paths.test.ts | 56 +++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 12 deletions(-) create mode 100644 test/storage-file-paths.test.ts diff --git a/lib/storage.ts b/lib/storage.ts index 28d1b439..cb7d705f 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -27,7 +27,6 @@ import { ACCOUNTS_BACKUP_SUFFIX, ACCOUNTS_WAL_SUFFIX, getFlaggedAccountsPath as buildFlaggedAccountsPath, - getLegacyFlaggedAccountsPath as buildLegacyFlaggedAccountsPath, getAccountsBackupPath, getAccountsBackupRecoveryCandidates, getAccountsWalPath, @@ -980,7 +979,7 @@ export function getFlaggedAccountsPath(): string { } function getLegacyFlaggedAccountsPath(): string { - return buildLegacyFlaggedAccountsPath( + return buildFlaggedAccountsPath( getStoragePath(), LEGACY_FLAGGED_ACCOUNTS_FILE_NAME, ); diff --git a/lib/storage/file-paths.ts b/lib/storage/file-paths.ts index 8a626258..e151afe7 100644 --- a/lib/storage/file-paths.ts +++ b/lib/storage/file-paths.ts @@ -9,10 +9,7 @@ export function getAccountsBackupPath(path: string): string { return `${path}${ACCOUNTS_BACKUP_SUFFIX}`; } -export function getAccountsBackupPathAtIndex( - path: string, - index: number, -): string { +function getAccountsBackupPathAtIndex(path: string, index: number): string { if (index <= 0) return getAccountsBackupPath(path); return `${path}${ACCOUNTS_BACKUP_SUFFIX}.${index}`; } @@ -37,12 +34,9 @@ export function getFlaggedAccountsPath( storagePath: string, fileName: string, ): string { - return join(dirname(storagePath), fileName); + return buildSiblingStoragePath(storagePath, fileName); } -export function getLegacyFlaggedAccountsPath( - storagePath: string, - legacyFileName: string, -): string { - return join(dirname(storagePath), legacyFileName); +function buildSiblingStoragePath(storagePath: string, fileName: string): string { + return join(dirname(storagePath), fileName); } diff --git a/test/storage-file-paths.test.ts b/test/storage-file-paths.test.ts new file mode 100644 index 00000000..9a9cda23 --- /dev/null +++ b/test/storage-file-paths.test.ts @@ -0,0 +1,56 @@ +import { dirname, join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + ACCOUNTS_BACKUP_SUFFIX, + ACCOUNTS_WAL_SUFFIX, + getAccountsBackupPath, + getAccountsBackupRecoveryCandidates, + getAccountsWalPath, + getFlaggedAccountsPath, + getIntentionalResetMarkerPath, + RESET_MARKER_SUFFIX, +} from "../lib/storage/file-paths.js"; + +describe("storage file paths", () => { + it("builds the primary backup, wal, and reset marker paths", () => { + const storagePath = "/tmp/openai-codex-accounts.json"; + + expect(getAccountsBackupPath(storagePath)).toBe( + `${storagePath}${ACCOUNTS_BACKUP_SUFFIX}`, + ); + expect(getAccountsWalPath(storagePath)).toBe( + `${storagePath}${ACCOUNTS_WAL_SUFFIX}`, + ); + expect(getIntentionalResetMarkerPath(storagePath)).toBe( + `${storagePath}${RESET_MARKER_SUFFIX}`, + ); + }); + + it("returns backup recovery candidates for the base backup and history slots", () => { + const storagePath = "/tmp/openai-codex-accounts.json"; + + expect(getAccountsBackupRecoveryCandidates(storagePath)).toEqual([ + `${storagePath}.bak`, + `${storagePath}.bak.1`, + `${storagePath}.bak.2`, + ]); + }); + + it("builds flagged storage paths next to the active storage file", () => { + const storagePath = "/tmp/config/openai-codex-accounts.json"; + const fileName = "openai-codex-flagged-accounts.json"; + + expect(getFlaggedAccountsPath(storagePath, fileName)).toBe( + join(dirname(storagePath), fileName), + ); + }); + + it("uses dirname/join semantics consistently for windows-like storage paths", () => { + const storagePath = String.raw`C:\Users\user\.codex\openai-codex-accounts.json`; + const fileName = "openai-codex-blocked-accounts.json"; + + expect(getFlaggedAccountsPath(storagePath, fileName)).toBe( + join(dirname(storagePath), fileName), + ); + }); +}); From 8181eccde47aadab2d317728c90448aa92b972dc Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:12:19 +0800 Subject: [PATCH 329/376] test: cover extracted backup metadata helpers --- lib/runtime/metrics.ts | 9 ++-- lib/storage.ts | 12 +---- lib/storage/backup-metadata.ts | 22 +++++---- test/backup-metadata.test.ts | 71 +++++++++++++++++++++++++++ test/runtime-metrics.test.ts | 88 ++++++++++++++++++++++++++++++++++ 5 files changed, 178 insertions(+), 24 deletions(-) create mode 100644 test/backup-metadata.test.ts create mode 100644 test/runtime-metrics.test.ts diff --git a/lib/runtime/metrics.ts b/lib/runtime/metrics.ts index b8d64ea6..49e95eb3 100644 --- a/lib/runtime/metrics.ts +++ b/lib/runtime/metrics.ts @@ -66,7 +66,10 @@ export function clampRetryHintMs(value: number): number | null { return Math.min(normalized, MAX_RETRY_HINT_MS); } -export function parseRetryAfterHintMs(headers: Headers): number | null { +export function parseRetryAfterHintMs( + headers: Headers, + now = Date.now(), +): number | null { const retryAfterMsHeader = headers.get("retry-after-ms")?.trim(); if (retryAfterMsHeader && /^\d+$/.test(retryAfterMsHeader)) { return clampRetryHintMs(Number.parseInt(retryAfterMsHeader, 10)); @@ -79,7 +82,7 @@ export function parseRetryAfterHintMs(headers: Headers): number | null { if (retryAfterHeader) { const retryAtMs = Date.parse(retryAfterHeader); if (Number.isFinite(retryAtMs)) { - return clampRetryHintMs(retryAtMs - Date.now()); + return clampRetryHintMs(retryAtMs - now); } } @@ -87,7 +90,7 @@ export function parseRetryAfterHintMs(headers: Headers): number | null { if (resetAtHeader && /^\d+$/.test(resetAtHeader)) { const resetRaw = Number.parseInt(resetAtHeader, 10); const resetAtMs = resetRaw < 10_000_000_000 ? resetRaw * 1000 : resetRaw; - return clampRetryHintMs(resetAtMs - Date.now()); + return clampRetryHintMs(resetAtMs - now); } return null; diff --git a/lib/storage.ts b/lib/storage.ts index 07ecd185..2c892ea6 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -13,6 +13,7 @@ import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; import { AnyAccountStorageSchema, getValidationErrors } from "./schemas.js"; import { type BackupMetadataSection, + type BackupSnapshotKind, type BackupSnapshotMetadata, buildMetadataSection, } from "./storage/backup-metadata.js"; @@ -102,17 +103,6 @@ type AccountStorageWithMetadata = AccountStorageV3 & { restoreReason?: RestoreReason; }; -type BackupSnapshotKind = - | "accounts-primary" - | "accounts-wal" - | "accounts-backup" - | "accounts-backup-history" - | "accounts-discovered-backup" - | "flagged-primary" - | "flagged-backup" - | "flagged-backup-history" - | "flagged-discovered-backup"; - export type BackupMetadata = { accounts: BackupMetadataSection; flaggedAccounts: BackupMetadataSection; diff --git a/lib/storage/backup-metadata.ts b/lib/storage/backup-metadata.ts index b2a7b14d..7eb4be8f 100644 --- a/lib/storage/backup-metadata.ts +++ b/lib/storage/backup-metadata.ts @@ -1,14 +1,16 @@ +export type BackupSnapshotKind = + | "accounts-primary" + | "accounts-wal" + | "accounts-backup" + | "accounts-backup-history" + | "accounts-discovered-backup" + | "flagged-primary" + | "flagged-backup" + | "flagged-backup-history" + | "flagged-discovered-backup"; + export type BackupSnapshotMetadata = { - kind: - | "accounts-primary" - | "accounts-wal" - | "accounts-backup" - | "accounts-backup-history" - | "accounts-discovered-backup" - | "flagged-primary" - | "flagged-backup" - | "flagged-backup-history" - | "flagged-discovered-backup"; + kind: BackupSnapshotKind; path: string; index?: number; exists: boolean; diff --git a/test/backup-metadata.test.ts b/test/backup-metadata.test.ts new file mode 100644 index 00000000..aadbbfe8 --- /dev/null +++ b/test/backup-metadata.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { + buildMetadataSection, + latestValidSnapshot, + type BackupSnapshotMetadata, +} from "../lib/storage/backup-metadata.js"; + +function createSnapshot( + overrides: Partial, +): BackupSnapshotMetadata { + return { + kind: "accounts-backup", + path: "/tmp/openai-codex-accounts.json.bak", + exists: true, + valid: true, + ...overrides, + }; +} + +describe("backup metadata helpers", () => { + it("returns undefined when every snapshot is invalid", () => { + const snapshots = [ + createSnapshot({ path: "/tmp/a.bak", valid: false }), + createSnapshot({ path: "/tmp/b.bak", valid: false, mtimeMs: 10 }), + ]; + + expect(latestValidSnapshot(snapshots)).toBeUndefined(); + }); + + it("keeps the first valid snapshot when mtimes tie", () => { + const first = createSnapshot({ path: "/tmp/first.bak", mtimeMs: 50 }); + const second = createSnapshot({ + path: "/tmp/second.bak", + kind: "accounts-backup-history", + mtimeMs: 50, + }); + + expect(latestValidSnapshot([first, second])).toEqual(first); + }); + + it("treats missing mtimes as zero when choosing the latest valid snapshot", () => { + const first = createSnapshot({ path: "/tmp/first.bak" }); + const second = createSnapshot({ + path: "/tmp/second.bak", + kind: "accounts-discovered-backup", + }); + + expect(latestValidSnapshot([first, second])).toEqual(first); + }); + + it("builds section counts and omits latestValidPath when no valid snapshots exist", () => { + const snapshots = [ + createSnapshot({ path: "/tmp/invalid-a.bak", valid: false }), + createSnapshot({ + path: "/tmp/invalid-b.bak", + kind: "accounts-backup-history", + valid: false, + }), + ]; + + expect(buildMetadataSection("/tmp/openai-codex-accounts.json", snapshots)).toEqual( + { + storagePath: "/tmp/openai-codex-accounts.json", + latestValidPath: undefined, + snapshotCount: 2, + validSnapshotCount: 0, + snapshots, + }, + ); + }); +}); diff --git a/test/runtime-metrics.test.ts b/test/runtime-metrics.test.ts new file mode 100644 index 00000000..f4fbb44c --- /dev/null +++ b/test/runtime-metrics.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { + MAX_RETRY_HINT_MS, + clampRetryHintMs, + createRuntimeMetrics, + parseEnvInt, + parseFailoverMode, + parseRetryAfterHintMs, + sanitizeResponseHeadersForLog, +} from "../lib/runtime/metrics.js"; + +describe("runtime metrics helpers", () => { + it("creates zeroed runtime metrics from an injected timestamp", () => { + expect(createRuntimeMetrics(1234)).toEqual({ + startedAt: 1234, + totalRequests: 0, + successfulRequests: 0, + failedRequests: 0, + rateLimitedResponses: 0, + serverErrors: 0, + networkErrors: 0, + userAborts: 0, + authRefreshFailures: 0, + emptyResponseRetries: 0, + accountRotations: 0, + sameAccountRetries: 0, + streamFailoverAttempts: 0, + streamFailoverRecoveries: 0, + streamFailoverCrossAccountRecoveries: 0, + cumulativeLatencyMs: 0, + lastRequestAt: null, + lastError: null, + }); + }); + + it("parses failover modes and integer env overrides conservatively", () => { + expect(parseFailoverMode("aggressive")).toBe("aggressive"); + expect(parseFailoverMode(" conservative ")).toBe("conservative"); + expect(parseFailoverMode("other")).toBe("balanced"); + expect(parseEnvInt("42")).toBe(42); + expect(parseEnvInt("abc")).toBeUndefined(); + expect(parseEnvInt(undefined)).toBeUndefined(); + }); + + it("clamps retry hints and drops invalid values", () => { + expect(clampRetryHintMs(-1)).toBeNull(); + expect(clampRetryHintMs(Number.NaN)).toBeNull(); + expect(clampRetryHintMs(MAX_RETRY_HINT_MS + 1000)).toBe(MAX_RETRY_HINT_MS); + expect(clampRetryHintMs(2500.9)).toBe(2500); + }); + + it("parses retry-after headers across ms, seconds, date, and reset formats", () => { + const now = Date.parse("2026-03-22T00:00:00.000Z"); + + const retryAfterMsHeaders = new Headers({ "retry-after-ms": "1500" }); + expect(parseRetryAfterHintMs(retryAfterMsHeaders, now)).toBe(1500); + + const retryAfterSecondsHeaders = new Headers({ "retry-after": "3" }); + expect(parseRetryAfterHintMs(retryAfterSecondsHeaders, now)).toBe(3000); + + const retryAfterDateHeaders = new Headers({ + "retry-after": "Sun, 22 Mar 2026 00:00:04 GMT", + }); + expect(parseRetryAfterHintMs(retryAfterDateHeaders, now)).toBe(4000); + + const resetSecondsHeaders = new Headers({ "x-ratelimit-reset": "1774137605" }); + expect(parseRetryAfterHintMs(resetSecondsHeaders, now)).toBe(5000); + + const resetMillisecondsHeaders = new Headers({ + "x-ratelimit-reset": String(now + 6000), + }); + expect(parseRetryAfterHintMs(resetMillisecondsHeaders, now)).toBe(6000); + }); + + it("keeps only allowlisted response headers for logging", () => { + const headers = new Headers({ + "content-type": "text/event-stream", + "x-request-id": "req_123", + authorization: "secret", + cookie: "sensitive", + }); + + expect(sanitizeResponseHeadersForLog(headers)).toEqual({ + "content-type": "text/event-stream", + "x-request-id": "req_123", + }); + }); +}); From 5ee807708d1de49517a7cd82f498063491a32209 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:15:09 +0800 Subject: [PATCH 330/376] refactor: share storage identity helpers --- lib/storage.ts | 43 ++++++----------------------------------- lib/storage/identity.ts | 4 ++-- test/storage.test.ts | 17 ++++++++++------ 3 files changed, 19 insertions(+), 45 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index b8a9ea56..69d51be6 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -16,13 +16,17 @@ import { AnyAccountStorageSchema, getValidationErrors } from "./schemas.js"; import { formatStorageErrorHint } from "./storage/error-hints.js"; export { StorageError } from "./errors.js"; -export { formatStorageErrorHint } from "./storage/error-hints.js"; +export { formatStorageErrorHint, toStorageError } from "./storage/error-hints.js"; export { getAccountIdentityKey, normalizeEmailKey, } from "./storage/identity.js"; -import { normalizeEmailKey } from "./storage/identity.js"; +import { + type AccountIdentityRef, + normalizeEmailKey, + toAccountIdentityRef, +} from "./storage/identity.js"; import { type AccountMetadataV1, type AccountMetadataV3, @@ -229,41 +233,6 @@ type AccountLike = { lastUsed?: number; }; -type AccountIdentityRef = { - accountId?: string; - emailKey?: string; - refreshToken?: string; -}; - -function normalizeAccountIdKey( - accountId: string | undefined, -): string | undefined { - if (!accountId) return undefined; - const trimmed = accountId.trim(); - return trimmed || undefined; -} - -function normalizeRefreshTokenKey( - refreshToken: string | undefined, -): string | undefined { - if (!refreshToken) return undefined; - const trimmed = refreshToken.trim(); - return trimmed || undefined; -} - -function toAccountIdentityRef( - account: - | Pick - | null - | undefined, -): AccountIdentityRef { - return { - accountId: normalizeAccountIdKey(account?.accountId), - emailKey: normalizeEmailKey(account?.email), - refreshToken: normalizeRefreshTokenKey(account?.refreshToken), - }; -} - function looksLikeSyntheticFixtureAccount(account: AccountMetadataV3): boolean { const email = typeof account.email === "string" ? account.email.trim().toLowerCase() : ""; diff --git a/lib/storage/identity.ts b/lib/storage/identity.ts index 42c301e9..d7d424b0 100644 --- a/lib/storage/identity.ts +++ b/lib/storage/identity.ts @@ -6,7 +6,7 @@ type AccountLike = { refreshToken?: string; }; -type AccountIdentityRef = { +export type AccountIdentityRef = { accountId?: string; emailKey?: string; refreshToken?: string; @@ -39,7 +39,7 @@ function hashRefreshTokenKey(refreshToken: string): string { return createHash("sha256").update(refreshToken).digest("hex"); } -function toAccountIdentityRef( +export function toAccountIdentityRef( account: | Pick | null diff --git a/test/storage.test.ts b/test/storage.test.ts index 93aba856..96388a9f 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -19,6 +19,7 @@ import { importAccounts, loadAccounts, loadFlaggedAccounts, + normalizeEmailKey, normalizeAccountStorage, resolveAccountSelectionIndex, saveFlaggedAccounts, @@ -26,15 +27,10 @@ import { saveAccounts, setStoragePath, setStoragePathDirect, + toStorageError, withAccountAndFlaggedStorageTransaction, withAccountStorageTransaction, } from "../lib/storage.js"; -import { toStorageError } from "../lib/storage/error-hints.js"; - -// Mocking the behavior we're about to implement for TDD -// Since the functions aren't in lib/storage.ts yet, we'll need to mock them or -// accept that this test won't even compile/run until we add them. -// But Task 0 says: "Tests should fail initially (RED phase)" describe("storage", () => { const _origCODEX_HOME = process.env.CODEX_HOME; @@ -99,6 +95,15 @@ describe("storage", () => { }); describe("account identity keys", () => { + it("normalizes mixed-case emails directly", () => { + expect(normalizeEmailKey(" User@Example.com ")).toBe("user@example.com"); + }); + + it("returns undefined for missing or blank emails", () => { + expect(normalizeEmailKey(undefined)).toBeUndefined(); + expect(normalizeEmailKey(" ")).toBeUndefined(); + }); + it("prefers accountId and normalized email when both are present", () => { expect( getAccountIdentityKey({ From 2371577e534819344ccae8ddaa331f076de2ddeb Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:19:40 +0800 Subject: [PATCH 331/376] test: harden report command path assertions --- test/codex-manager-report-command.test.ts | 182 ++++++++++++++++++++-- 1 file changed, 170 insertions(+), 12 deletions(-) diff --git a/test/codex-manager-report-command.test.ts b/test/codex-manager-report-command.test.ts index c50c7e83..17644da1 100644 --- a/test/codex-manager-report-command.test.ts +++ b/test/codex-manager-report-command.test.ts @@ -5,22 +5,24 @@ import { } from "../lib/codex-manager/commands/report.js"; import type { AccountStorageV3 } from "../lib/storage.js"; -function createStorage(): AccountStorageV3 { +function createStorage( + accounts: AccountStorageV3["accounts"] = [ + { + email: "one@example.com", + refreshToken: "refresh-token-1", + accessToken: "access-token-1", + expiresAt: 10, + addedAt: 1, + lastUsed: 1, + enabled: true, + }, + ], +): AccountStorageV3 { return { version: 3, activeIndex: 0, activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "one@example.com", - refreshToken: "refresh-token-1", - accessToken: "access-token-1", - expiresAt: 10, - addedAt: 1, - lastUsed: 1, - enabled: true, - }, - ], + accounts, }; } @@ -94,4 +96,160 @@ describe("runReportCommand", () => { expect.stringContaining('"forecast"'), ); }); + + it("covers live probe refresh failures, missing account ids, and probe errors", async () => { + const deps = createDeps({ + loadAccounts: vi.fn(async () => + createStorage([ + { + email: "refresh-fail@example.com", + refreshToken: "refresh-fail", + addedAt: 1, + lastUsed: 1, + enabled: true, + }, + { + email: "missing-id@example.com", + refreshToken: "missing-id", + addedAt: 2, + lastUsed: 2, + enabled: true, + }, + { + email: "probe-error@example.com", + refreshToken: "probe-error", + accountId: "acct-probe-error", + addedAt: 3, + lastUsed: 3, + enabled: true, + }, + { + email: "ok@example.com", + refreshToken: "ok-refresh", + accountId: "acct-ok", + addedAt: 4, + lastUsed: 4, + enabled: true, + }, + ]), + ), + resolveActiveIndex: vi.fn(() => 3), + queuedRefresh: vi.fn(async (refreshToken: string) => { + if (refreshToken === "refresh-fail") { + return { + type: "error", + reason: "auth-failure", + message: "token expired", + }; + } + return { + type: "success", + access: + refreshToken === "missing-id" + ? "not-a-jwt" + : `access-${refreshToken}`, + refresh: refreshToken, + expires: 100, + idToken: `id-${refreshToken}`, + }; + }), + fetchCodexQuotaSnapshot: vi.fn(async ({ accountId }) => { + if (accountId === "acct-probe-error") { + throw new Error("quota endpoint down"); + } + return { + status: 200, + model: "gpt-5-codex", + planType: "pro", + primary: {}, + secondary: {}, + }; + }), + }); + + const result = await runReportCommand(["--live", "--json"], deps); + + expect(result).toBe(0); + expect(deps.fetchCodexQuotaSnapshot).toHaveBeenCalledTimes(2); + const jsonOutput = JSON.parse( + (deps.logInfo as ReturnType).mock.calls.at(-1)?.[0] ?? "{}", + ) as { + forecast: { + probeErrors: string[]; + accounts: Array<{ refreshFailure?: { message?: string }; liveQuota?: { planType?: string } }>; + }; + }; + expect(jsonOutput.forecast.probeErrors).toEqual( + expect.arrayContaining([ + expect.stringContaining("missing accountId for live probe"), + expect.stringContaining("quota endpoint down"), + ]), + ); + expect(jsonOutput.forecast.accounts[0]?.refreshFailure?.message).toBe( + "token expired", + ); + expect(jsonOutput.forecast.accounts[3]?.liveQuota?.planType).toBe("pro"); + }); + + it("prints a human-readable report and announces the output path", async () => { + const deps = createDeps(); + + const result = await runReportCommand(["--out", "report.json"], deps); + + expect(result).toBe(0); + const [[writtenPath, writtenReport]] = ( + deps.writeFile as ReturnType + ).mock.calls; + expect(String(writtenPath).replaceAll("\\", "/")).toContain( + "/repo/report.json", + ); + expect(String(writtenReport)).toContain('"command": "report"'); + const infoLines = (deps.logInfo as ReturnType).mock.calls.map( + ([message]) => String(message).replaceAll("\\", "/"), + ); + expect(infoLines.some((line) => line.includes("Accounts: 1 total"))).toBe( + true, + ); + expect( + infoLines.some((line) => line.includes("Recommendation: account 1")), + ).toBe(true); + expect( + infoLines.some( + (line) => + line.startsWith("Report written: ") && + line.endsWith("/repo/report.json"), + ), + ).toBe(true); + }); + + it("reports an empty storage snapshot when no accounts are loaded", async () => { + const deps = createDeps({ + loadAccounts: vi.fn(async () => null), + }); + + const result = await runReportCommand(["--json"], deps); + + expect(result).toBe(0); + expect(deps.resolveActiveIndex).not.toHaveBeenCalled(); + const jsonOutput = JSON.parse( + (deps.logInfo as ReturnType).mock.calls.at(-1)?.[0] ?? "{}", + ) as { + accounts: { total: number }; + activeIndex: number | null; + }; + expect(jsonOutput.accounts.total).toBe(0); + expect(jsonOutput.activeIndex).toBeNull(); + }); + + it("surfaces write failures from the injected file writer", async () => { + const deps = createDeps({ + writeFile: vi.fn(async () => { + throw new Error("disk full"); + }), + }); + + await expect( + runReportCommand(["--json", "--out", "report.json"], deps), + ).rejects.toThrow("disk full"); + }); }); From c307cf56810b354453fcf98e82e6c5002159b09e Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:25:40 +0800 Subject: [PATCH 332/376] test: cover extracted auth command modules --- lib/codex-manager/auth-commands.ts | 17 +- lib/codex-manager/help.ts | 31 +- test/codex-manager-auth-commands.test.ts | 399 +++++++++++++++++++++++ test/codex-manager-help.test.ts | 63 ++++ 4 files changed, 497 insertions(+), 13 deletions(-) create mode 100644 test/codex-manager-auth-commands.test.ts create mode 100644 test/codex-manager-help.test.ts diff --git a/lib/codex-manager/auth-commands.ts b/lib/codex-manager/auth-commands.ts index 9d3f7c80..55ccb813 100644 --- a/lib/codex-manager/auth-commands.ts +++ b/lib/codex-manager/auth-commands.ts @@ -264,17 +264,21 @@ export async function runSwitch( return 0; } +/** + * `codex auth best` still follows the monolith's single-writer storage pattern. + * Callers should keep concurrent CLI dispatches serialized while the live probe + * path mutates refreshed tokens before persisting them back to disk. + */ export async function runBest( args: string[], helpers: AuthCommandHelpers, ): Promise { - if (args.includes("--help") || args.includes("-h")) { - printBestUsage(); - return 0; - } - const parsedArgs = parseBestArgs(args); if (!parsedArgs.ok) { + if (parsedArgs.reason === "help") { + printBestUsage(); + return 0; + } console.error(parsedArgs.message); printBestUsage(); return 1; @@ -506,11 +510,12 @@ export async function runAuthLogin( ): Promise { const parsedArgs = parseAuthLoginArgs(args); if (!parsedArgs.ok) { - if (parsedArgs.message) { + if (parsedArgs.reason === "error") { console.error(parsedArgs.message); printUsage(); return 1; } + printUsage(); return 0; } diff --git a/lib/codex-manager/help.ts b/lib/codex-manager/help.ts index 6756114b..395596c2 100644 --- a/lib/codex-manager/help.ts +++ b/lib/codex-manager/help.ts @@ -37,7 +37,8 @@ export type AuthLoginOptions = { export type ParsedAuthLoginArgs = | { ok: true; options: AuthLoginOptions } - | { ok: false; message: string }; + | { ok: false; reason: "help" } + | { ok: false; reason: "error"; message: string }; export function parseAuthLoginArgs(args: string[]): ParsedAuthLoginArgs { const options: AuthLoginOptions = { @@ -50,11 +51,11 @@ export function parseAuthLoginArgs(args: string[]): ParsedAuthLoginArgs { continue; } if (arg === "--help" || arg === "-h") { - printUsage(); - return { ok: false, message: "" }; + return { ok: false, reason: "help" }; } return { ok: false, + reason: "error", message: `Unknown login option: ${arg}`, }; } @@ -71,7 +72,8 @@ export interface BestCliOptions { export type ParsedBestArgs = | { ok: true; options: BestCliOptions } - | { ok: false; message: string }; + | { ok: false; reason: "help" } + | { ok: false; reason: "error"; message: string }; export function printBestUsage(): void { console.log( @@ -102,6 +104,9 @@ export function parseBestArgs(args: string[]): ParsedBestArgs { for (let i = 0; i < args.length; i += 1) { const arg = args[i]; if (!arg) continue; + if (arg === "--help" || arg === "-h") { + return { ok: false, reason: "help" }; + } if (arg === "--live" || arg === "-l") { options.live = true; continue; @@ -113,7 +118,11 @@ export function parseBestArgs(args: string[]): ParsedBestArgs { if (arg === "--model" || arg === "-m") { const value = args[i + 1]; if (!value) { - return { ok: false, message: "Missing value for --model" }; + return { + ok: false, + reason: "error", + message: "Missing value for --model", + }; } options.model = value; options.modelProvided = true; @@ -123,13 +132,21 @@ export function parseBestArgs(args: string[]): ParsedBestArgs { if (arg.startsWith("--model=")) { const value = arg.slice("--model=".length).trim(); if (!value) { - return { ok: false, message: "Missing value for --model" }; + return { + ok: false, + reason: "error", + message: "Missing value for --model", + }; } options.model = value; options.modelProvided = true; continue; } - return { ok: false, message: `Unknown option: ${arg}` }; + return { + ok: false, + reason: "error", + message: `Unknown option: ${arg}`, + }; } return { ok: true, options }; diff --git a/test/codex-manager-auth-commands.test.ts b/test/codex-manager-auth-commands.test.ts new file mode 100644 index 00000000..e3c93904 --- /dev/null +++ b/test/codex-manager-auth-commands.test.ts @@ -0,0 +1,399 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ExistingAccountInfo } from "../lib/cli.js"; +import type { + DashboardDisplaySettings, +} from "../lib/dashboard-settings.js"; +import type { QuotaCacheData } from "../lib/quota-cache.js"; +import type { AccountStorageV3, NamedBackupSummary } from "../lib/storage.js"; +import type { TokenResult } from "../lib/types.js"; + +const { + extractAccountEmailMock, + extractAccountIdMock, + formatAccountLabelMock, + sanitizeEmailMock, + promptAddAnotherAccountMock, + promptLoginModeMock, + loadDashboardDisplaySettingsMock, + loadQuotaCacheMock, + fetchCodexQuotaSnapshotMock, + queuedRefreshMock, + loadAccountsMock, + loadFlaggedAccountsMock, + saveAccountsMock, + setStoragePathMock, + getNamedBackupsMock, + restoreAccountsFromBackupMock, + setCodexCliActiveSelectionMock, + confirmMock, + applyUiThemeFromDashboardSettingsMock, + configureUnifiedSettingsMock, +} = vi.hoisted(() => ({ + extractAccountEmailMock: vi.fn(), + extractAccountIdMock: vi.fn(), + formatAccountLabelMock: vi.fn(), + sanitizeEmailMock: vi.fn(), + promptAddAnotherAccountMock: vi.fn(), + promptLoginModeMock: vi.fn(), + loadDashboardDisplaySettingsMock: vi.fn(), + loadQuotaCacheMock: vi.fn(), + fetchCodexQuotaSnapshotMock: vi.fn(), + queuedRefreshMock: vi.fn(), + loadAccountsMock: vi.fn(), + loadFlaggedAccountsMock: vi.fn(), + saveAccountsMock: vi.fn(), + setStoragePathMock: vi.fn(), + getNamedBackupsMock: vi.fn(), + restoreAccountsFromBackupMock: vi.fn(), + setCodexCliActiveSelectionMock: vi.fn(), + confirmMock: vi.fn(), + applyUiThemeFromDashboardSettingsMock: vi.fn(), + configureUnifiedSettingsMock: vi.fn(), +})); + +vi.mock("../lib/auth/browser.js", () => ({ + isBrowserLaunchSuppressed: vi.fn(() => false), +})); + +vi.mock("../lib/accounts.js", () => ({ + extractAccountEmail: extractAccountEmailMock, + extractAccountId: extractAccountIdMock, + formatAccountLabel: formatAccountLabelMock, + sanitizeEmail: sanitizeEmailMock, +})); + +vi.mock("../lib/cli.js", () => ({ + promptAddAnotherAccount: promptAddAnotherAccountMock, + promptLoginMode: promptLoginModeMock, +})); + +vi.mock("../lib/dashboard-settings.js", () => ({ + loadDashboardDisplaySettings: loadDashboardDisplaySettingsMock, +})); + +vi.mock("../lib/quota-cache.js", () => ({ + loadQuotaCache: loadQuotaCacheMock, +})); + +vi.mock("../lib/quota-probe.js", () => ({ + fetchCodexQuotaSnapshot: fetchCodexQuotaSnapshotMock, +})); + +vi.mock("../lib/refresh-queue.js", () => ({ + queuedRefresh: queuedRefreshMock, +})); + +vi.mock("../lib/storage.js", async () => { + const actual = await vi.importActual("../lib/storage.js"); + return { + ...(actual as object), + loadAccounts: loadAccountsMock, + loadFlaggedAccounts: loadFlaggedAccountsMock, + saveAccounts: saveAccountsMock, + setStoragePath: setStoragePathMock, + getNamedBackups: getNamedBackupsMock, + restoreAccountsFromBackup: restoreAccountsFromBackupMock, + }; +}); + +vi.mock("../lib/codex-cli/writer.js", () => ({ + setCodexCliActiveSelection: setCodexCliActiveSelectionMock, +})); + +vi.mock("../lib/ui/confirm.js", () => ({ + confirm: confirmMock, +})); + +vi.mock("../lib/codex-manager/settings-hub.js", () => ({ + applyUiThemeFromDashboardSettings: applyUiThemeFromDashboardSettingsMock, + configureUnifiedSettings: configureUnifiedSettingsMock, +})); + +import { + persistAndSyncSelectedAccount, + runAuthLogin, + runBest, + runSwitch, + type AuthCommandHelpers, + type AuthLoginCommandDeps, +} from "../lib/codex-manager/auth-commands.js"; + +function createStorage( + accounts: AccountStorageV3["accounts"] = [ + { + email: "one@example.com", + refreshToken: "refresh-token-1", + accessToken: "access-token-1", + accountId: "acct-1", + expiresAt: Date.now() + 60_000, + addedAt: 1, + lastUsed: 1, + enabled: true, + }, + ], +): AccountStorageV3 { + return { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts, + }; +} + +function createHelpers( + overrides: Partial = {}, +): AuthCommandHelpers { + return { + resolveActiveIndex: vi.fn((storage: AccountStorageV3) => storage.activeIndex), + hasUsableAccessToken: vi.fn(() => true), + applyTokenAccountIdentity: vi.fn( + (account, tokenAccountId) => { + if (!tokenAccountId) return false; + account.accountId = tokenAccountId; + account.accountIdSource = "token"; + return true; + }, + ), + normalizeFailureDetail: vi.fn( + (message: string | undefined, reason: string | undefined) => + message ?? reason ?? "unknown", + ), + ...overrides, + }; +} + +function createAuthLoginDeps( + overrides: Partial = {}, +): AuthLoginCommandDeps { + return { + ...createHelpers(), + stylePromptText: vi.fn((text: string) => text), + runActionPanel: vi.fn(async (_title, _stage, action) => { + await action(); + }), + toExistingAccountInfo: vi.fn( + (): ExistingAccountInfo[] => [], + ), + countMenuQuotaRefreshTargets: vi.fn(() => 0), + defaultMenuQuotaRefreshTtlMs: 60_000, + refreshQuotaCacheForMenu: vi.fn( + async (_storage, cache: QuotaCacheData): Promise => cache, + ), + clearAccountsAndReset: vi.fn(async () => undefined), + handleManageAction: vi.fn(async () => undefined), + promptOAuthSignInMode: vi.fn( + async ( + _backupOption: NamedBackupSummary | null, + _backupDiscoveryWarning?: string | null, + ) => "cancel" as const, + ), + promptBackupRestoreMode: vi.fn( + async (_latestBackup: NamedBackupSummary) => "back" as const, + ), + promptManualBackupSelection: vi.fn( + async (_namedBackups: NamedBackupSummary[]) => null, + ), + runOAuthFlow: vi.fn( + async (): Promise => ({ + type: "success", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + idToken: "id-token", + }), + ), + resolveAccountSelection: vi.fn((tokens) => tokens), + persistAccountPool: vi.fn(async () => undefined), + syncSelectionToCodex: vi.fn(async () => undefined), + runHealthCheck: vi.fn(async () => undefined), + runForecast: vi.fn(async () => 0), + runFix: vi.fn(async () => 0), + runVerifyFlagged: vi.fn(async () => 0), + log: { + debug: vi.fn(), + }, + ...overrides, + }; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-22T19:30:00.000Z")); + vi.clearAllMocks(); + + extractAccountEmailMock.mockReturnValue(undefined); + extractAccountIdMock.mockReturnValue("acct-refreshed"); + formatAccountLabelMock.mockImplementation( + (account: { email?: string }, index: number) => + account.email ? `${index + 1}. ${account.email}` : `Account ${index + 1}`, + ); + sanitizeEmailMock.mockImplementation( + (email: string | undefined) => + typeof email === "string" ? email.toLowerCase() : undefined, + ); + loadDashboardDisplaySettingsMock.mockResolvedValue( + {} satisfies DashboardDisplaySettings, + ); + loadQuotaCacheMock.mockResolvedValue({} satisfies QuotaCacheData); + loadFlaggedAccountsMock.mockResolvedValue({ + version: 3, + accounts: [], + }); + saveAccountsMock.mockResolvedValue(undefined); + setCodexCliActiveSelectionMock.mockResolvedValue(true); + queuedRefreshMock.mockResolvedValue({ + type: "success", + access: "fresh-access-token", + refresh: "fresh-refresh-token", + expires: Date.now() + 60_000, + idToken: "fresh-id-token", + }); + fetchCodexQuotaSnapshotMock.mockResolvedValue({ + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + }); + confirmMock.mockResolvedValue(true); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("codex-manager auth command helpers", () => { + it("re-enables a selected account, refreshes missing tokens, and syncs it", async () => { + extractAccountEmailMock.mockReturnValue("Refreshed@Example.com"); + const storage = createStorage([ + { + email: "disabled@example.com", + refreshToken: "stale-refresh-token", + addedAt: 1, + lastUsed: 1, + enabled: false, + }, + ]); + const helpers = createHelpers({ + hasUsableAccessToken: vi.fn(() => false), + }); + + const result = await persistAndSyncSelectedAccount({ + storage, + targetIndex: 0, + parsed: 1, + switchReason: "rotation", + helpers, + }); + + expect(result).toEqual({ synced: true, wasDisabled: true }); + expect(storage.accounts[0]).toMatchObject({ + email: "refreshed@example.com", + refreshToken: "fresh-refresh-token", + accessToken: "fresh-access-token", + accountId: "acct-refreshed", + accountIdSource: "token", + enabled: true, + lastSwitchReason: "rotation", + }); + expect(saveAccountsMock).toHaveBeenCalledWith(storage); + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "acct-refreshed", + email: "refreshed@example.com", + accessToken: "fresh-access-token", + refreshToken: "fresh-refresh-token", + idToken: "fresh-id-token", + }), + ); + }); + + it("keeps switching when refresh fails and surfaces the warning", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + queuedRefreshMock.mockResolvedValue({ + type: "error", + reason: "auth-failure", + message: "refresh expired", + }); + setCodexCliActiveSelectionMock.mockResolvedValue(false); + const storage = createStorage([ + { + email: "warning@example.com", + refreshToken: "warning-refresh-token", + accessToken: "existing-access-token", + accountId: "acct-warning", + expiresAt: Date.now() - 1, + addedAt: 1, + lastUsed: 1, + enabled: true, + }, + ]); + const helpers = createHelpers({ + hasUsableAccessToken: vi.fn(() => false), + }); + + const result = await persistAndSyncSelectedAccount({ + storage, + targetIndex: 0, + parsed: 1, + switchReason: "best", + helpers, + }); + + expect(result).toEqual({ synced: false, wasDisabled: false }); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("refresh expired"), + ); + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "acct-warning", + accessToken: "existing-access-token", + }), + ); + }); + + it("validates switch indices before mutating storage", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + loadAccountsMock.mockResolvedValue(createStorage()); + + await expect(runSwitch([], createHelpers())).resolves.toBe(1); + await expect(runSwitch(["bogus"], createHelpers())).resolves.toBe(1); + await expect(runSwitch(["2"], createHelpers())).resolves.toBe(1); + + expect(errorSpy.mock.calls.map(([message]) => String(message))).toEqual( + expect.arrayContaining([ + "Missing index. Usage: codex auth switch ", + "Invalid index: bogus", + "Index out of range. Valid range: 1-1", + ]), + ); + }); + + it("reports the current best account directly from the extracted command", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + loadAccountsMock.mockResolvedValue(createStorage()); + + const result = await runBest(["--json"], createHelpers()); + + expect(result).toBe(0); + const output = JSON.parse(String(logSpy.mock.calls.at(-1)?.[0] ?? "{}")) as { + message: string; + accountIndex: number; + }; + expect(output.message).toContain("Already on best account"); + expect(output.accountIndex).toBe(1); + }); + + it("prints usage from runAuthLogin without entering the interactive flow", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + const deps = createAuthLoginDeps(); + + const result = await runAuthLogin(["--help"], deps); + + expect(result).toBe(0); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("Codex Multi-Auth CLI"), + ); + expect(loadAccountsMock).not.toHaveBeenCalled(); + }); +}); diff --git a/test/codex-manager-help.test.ts b/test/codex-manager-help.test.ts new file mode 100644 index 00000000..2bb84355 --- /dev/null +++ b/test/codex-manager-help.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from "vitest"; +import { + parseAuthLoginArgs, + parseBestArgs, +} from "../lib/codex-manager/help.js"; + +describe("codex-manager help parsers", () => { + it("parses login flags without printing usage", () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + + expect(parseAuthLoginArgs(["--manual"])).toEqual({ + ok: true, + options: { manual: true }, + }); + expect(parseAuthLoginArgs(["--no-browser"])).toEqual({ + ok: true, + options: { manual: true }, + }); + expect(parseAuthLoginArgs(["--help"])).toEqual({ + ok: false, + reason: "help", + }); + expect(logSpy).not.toHaveBeenCalled(); + logSpy.mockRestore(); + }); + + it("reports login parser errors explicitly", () => { + expect(parseAuthLoginArgs(["--bogus"])).toEqual({ + ok: false, + reason: "error", + message: "Unknown login option: --bogus", + }); + }); + + it("parses best args and treats help as a first-class result", () => { + expect(parseBestArgs(["--live", "--json", "--model", "gpt-5"])).toEqual({ + ok: true, + options: { + live: true, + json: true, + model: "gpt-5", + modelProvided: true, + }, + }); + expect(parseBestArgs(["-h"])).toEqual({ + ok: false, + reason: "help", + }); + }); + + it("reports missing model values and unknown flags", () => { + expect(parseBestArgs(["--model"])).toEqual({ + ok: false, + reason: "error", + message: "Missing value for --model", + }); + expect(parseBestArgs(["--bogus"])).toEqual({ + ok: false, + reason: "error", + message: "Unknown option: --bogus", + }); + }); +}); From d48cfa872b4cc1ae4c94f811c6db32aed36354b9 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:35:31 +0800 Subject: [PATCH 333/376] fix: clamp restored auth family indices --- lib/codex-manager/auth-commands.ts | 29 ++- test/codex-manager-auth-commands.test.ts | 235 +++++++++++++++++++++++ 2 files changed, 261 insertions(+), 3 deletions(-) diff --git a/lib/codex-manager/auth-commands.ts b/lib/codex-manager/auth-commands.ts index 55ccb813..26b9643d 100644 --- a/lib/codex-manager/auth-commands.ts +++ b/lib/codex-manager/auth-commands.ts @@ -33,7 +33,7 @@ import { } from "../storage.js"; import type { AccountIdSource, TokenFailure, TokenResult } from "../types.js"; import { setCodexCliActiveSelection } from "../codex-cli/writer.js"; -import type { ModelFamily } from "../prompts/codex.js"; +import { MODEL_FAMILIES, type ModelFamily } from "../prompts/codex.js"; import { UI_COPY } from "../ui/copy.js"; import { confirm } from "../ui/confirm.js"; import { @@ -134,6 +134,27 @@ export interface AuthLoginCommandDeps extends AuthCommandHelpers { }; } +function clampPreservedActiveIndexByFamily( + storage: AccountStorageV3, + targetIndex: number, +): void { + const maxIndex = Math.max(0, storage.accounts.length - 1); + const existingByFamily = storage.activeIndexByFamily ?? {}; + const nextByFamily: Record = {}; + + for (const family of MODEL_FAMILIES) { + const candidate = existingByFamily[family]; + if (typeof candidate !== "number" || !Number.isInteger(candidate)) continue; + nextByFamily[family] = Math.min( + maxIndex, + Math.max(0, candidate), + ); + } + + nextByFamily.codex = targetIndex; + storage.activeIndexByFamily = nextByFamily; +} + export async function persistAndSyncSelectedAccount({ storage, targetIndex, @@ -162,10 +183,12 @@ export async function persistAndSyncSelectedAccount({ } storage.activeIndex = targetIndex; - if (!storage.activeIndexByFamily || !preserveActiveIndexByFamily) { + if (storage.activeIndexByFamily && preserveActiveIndexByFamily) { + clampPreservedActiveIndexByFamily(storage, targetIndex); + } else { storage.activeIndexByFamily = {}; + storage.activeIndexByFamily.codex = targetIndex; } - storage.activeIndexByFamily.codex = targetIndex; const switchNow = Date.now(); let syncAccessToken = account.accessToken; diff --git a/test/codex-manager-auth-commands.test.ts b/test/codex-manager-auth-commands.test.ts index e3c93904..91b86e7a 100644 --- a/test/codex-manager-auth-commands.test.ts +++ b/test/codex-manager-auth-commands.test.ts @@ -11,6 +11,7 @@ const { extractAccountEmailMock, extractAccountIdMock, formatAccountLabelMock, + formatWaitTimeMock, sanitizeEmailMock, promptAddAnotherAccountMock, promptLoginModeMock, @@ -32,6 +33,7 @@ const { extractAccountEmailMock: vi.fn(), extractAccountIdMock: vi.fn(), formatAccountLabelMock: vi.fn(), + formatWaitTimeMock: vi.fn(), sanitizeEmailMock: vi.fn(), promptAddAnotherAccountMock: vi.fn(), promptLoginModeMock: vi.fn(), @@ -59,6 +61,7 @@ vi.mock("../lib/accounts.js", () => ({ extractAccountEmail: extractAccountEmailMock, extractAccountId: extractAccountIdMock, formatAccountLabel: formatAccountLabelMock, + formatWaitTime: formatWaitTimeMock, sanitizeEmail: sanitizeEmailMock, })); @@ -227,6 +230,7 @@ beforeEach(() => { (account: { email?: string }, index: number) => account.email ? `${index + 1}. ${account.email}` : `Account ${index + 1}`, ); + formatWaitTimeMock.mockImplementation((waitMs: number) => `${waitMs}ms`); sanitizeEmailMock.mockImplementation( (email: string | undefined) => typeof email === "string" ? email.toLowerCase() : undefined, @@ -239,6 +243,8 @@ beforeEach(() => { version: 3, accounts: [], }); + getNamedBackupsMock.mockResolvedValue([]); + restoreAccountsFromBackupMock.mockResolvedValue(createStorage()); saveAccountsMock.mockResolvedValue(undefined); setCodexCliActiveSelectionMock.mockResolvedValue(true); queuedRefreshMock.mockResolvedValue({ @@ -255,6 +261,7 @@ beforeEach(() => { secondary: {}, }); confirmMock.mockResolvedValue(true); + promptLoginModeMock.mockResolvedValue({ mode: "cancel" }); }); afterEach(() => { @@ -308,6 +315,67 @@ describe("codex-manager auth command helpers", () => { ); }); + it("clamps preserved family indices when restoring smaller account pools", async () => { + const storage = createStorage([ + { + email: "first@example.com", + refreshToken: "refresh-token-1", + accessToken: "access-token-1", + accountId: "acct-1", + expiresAt: Date.now() + 60_000, + addedAt: 1, + lastUsed: 1, + enabled: true, + }, + { + email: "second@example.com", + refreshToken: "refresh-token-2", + accessToken: "access-token-2", + accountId: "acct-2", + expiresAt: Date.now() + 60_000, + addedAt: 2, + lastUsed: 2, + enabled: true, + }, + { + email: "third@example.com", + refreshToken: "refresh-token-3", + accessToken: "access-token-3", + accountId: "acct-3", + expiresAt: Date.now() + 60_000, + addedAt: 3, + lastUsed: 3, + enabled: true, + }, + ]); + storage.activeIndexByFamily = { + codex: 0, + "gpt-5-codex": 1, + "codex-max": 2, + "gpt-5.2": 9, + "gpt-5.1": -4, + }; + + await expect( + persistAndSyncSelectedAccount({ + storage, + targetIndex: 1, + parsed: 2, + switchReason: "restore", + preserveActiveIndexByFamily: true, + helpers: createHelpers(), + }), + ).resolves.toEqual({ synced: true, wasDisabled: false }); + + expect(storage.activeIndexByFamily).toEqual({ + codex: 1, + "gpt-5-codex": 1, + "codex-max": 2, + "gpt-5.2": 2, + "gpt-5.1": 0, + }); + }); + it("keeps switching when refresh fails and surfaces the warning", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); queuedRefreshMock.mockResolvedValue({ @@ -384,6 +452,173 @@ describe("codex-manager auth command helpers", () => { expect(output.accountIndex).toBe(1); }); + it("reports json output when runBest switches to a healthier account", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + setCodexCliActiveSelectionMock.mockResolvedValue(false); + const storage = createStorage([ + { + email: "current@example.com", + refreshToken: "refresh-token-current", + accessToken: "access-token-current", + accountId: "acct-current", + expiresAt: Date.now() + 60_000, + addedAt: 1, + lastUsed: 1, + enabled: true, + coolingDownUntil: Date.now() + 60_000, + }, + { + email: "best@example.com", + refreshToken: "refresh-token-best", + accessToken: "access-token-best", + accountId: "acct-best", + expiresAt: Date.now() + 60_000, + addedAt: 2, + lastUsed: 2, + enabled: true, + }, + ]); + loadAccountsMock.mockResolvedValue(storage); + + const result = await runBest(["--json"], createHelpers()); + + expect(result).toBe(0); + const output = JSON.parse(String(logSpy.mock.calls.at(-1)?.[0] ?? "{}")) as { + message: string; + accountIndex: number; + synced: boolean; + wasDisabled: boolean; + }; + expect(output).toMatchObject({ + accountIndex: 2, + synced: false, + wasDisabled: false, + }); + expect(output.message).toContain("Switched to best account"); + expect(storage.activeIndex).toBe(1); + }); + + it("warns when runBest switches locally but cannot sync Codex auth", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + setCodexCliActiveSelectionMock.mockResolvedValue(false); + loadAccountsMock.mockResolvedValue( + createStorage([ + { + email: "current@example.com", + refreshToken: "refresh-token-current", + accessToken: "access-token-current", + accountId: "acct-current", + expiresAt: Date.now() + 60_000, + addedAt: 1, + lastUsed: 1, + enabled: true, + coolingDownUntil: Date.now() + 60_000, + }, + { + email: "best@example.com", + refreshToken: "refresh-token-best", + accessToken: "access-token-best", + accountId: "acct-best", + expiresAt: Date.now() + 60_000, + addedAt: 2, + lastUsed: 2, + enabled: true, + }, + ]), + ); + + const result = await runBest([], createHelpers()); + + expect(result).toBe(0); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("Switched to best account 2"), + ); + expect(warnSpy).toHaveBeenCalledWith( + "Codex auth sync did not complete. Multi-auth routing will still use this account.", + ); + }); + + it("restores a backup through the extracted login flow and clamps family indices", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + const restoredStorage = createStorage([ + { + email: "first@example.com", + refreshToken: "refresh-token-1", + accessToken: "access-token-1", + accountId: "acct-1", + expiresAt: Date.now() + 60_000, + addedAt: 1, + lastUsed: 1, + enabled: true, + }, + { + email: "second@example.com", + refreshToken: "refresh-token-2", + accessToken: "access-token-2", + accountId: "acct-2", + expiresAt: Date.now() + 60_000, + addedAt: 2, + lastUsed: 2, + enabled: true, + }, + { + email: "third@example.com", + refreshToken: "refresh-token-3", + accessToken: "access-token-3", + accountId: "acct-3", + expiresAt: Date.now() + 60_000, + addedAt: 3, + lastUsed: 3, + enabled: true, + }, + ]); + restoredStorage.activeIndex = 1; + restoredStorage.activeIndexByFamily = { + codex: 0, + "gpt-5-codex": 1, + "codex-max": 7, + "gpt-5.2": 9, + "gpt-5.1": -3, + }; + loadAccountsMock + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(restoredStorage) + .mockResolvedValueOnce(restoredStorage); + getNamedBackupsMock.mockResolvedValue([ + { + path: "/mock/backups/latest.json", + fileName: "latest.json", + accountCount: 3, + mtimeMs: Date.now(), + }, + ]); + restoreAccountsFromBackupMock.mockResolvedValue(restoredStorage); + const deps = createAuthLoginDeps({ + promptOAuthSignInMode: vi.fn(async () => "restore-backup" as const), + promptBackupRestoreMode: vi.fn(async () => "latest" as const), + }); + + const result = await runAuthLogin([], deps); + + expect(result).toBe(0); + expect(confirmMock).toHaveBeenCalledTimes(1); + expect(restoreAccountsFromBackupMock).toHaveBeenCalledWith( + "/mock/backups/latest.json", + { persist: false }, + ); + expect(saveAccountsMock).toHaveBeenCalledWith(restoredStorage); + expect(restoredStorage.activeIndexByFamily).toEqual({ + codex: 1, + "gpt-5-codex": 1, + "codex-max": 2, + "gpt-5.2": 2, + "gpt-5.1": 0, + }); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("latest.json")); + }); + it("prints usage from runAuthLogin without entering the interactive flow", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); const deps = createAuthLoginDeps(); From 163377eafb92b1f3fa2e644fb3bd9f93e8e38531 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:41:48 +0800 Subject: [PATCH 334/376] warn on reasoning effort coercion --- lib/request/request-transformer.ts | 12 ++++++++++++ test/config.test.ts | 25 ++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/lib/request/request-transformer.ts b/lib/request/request-transformer.ts index 6c002476..0e198929 100644 --- a/lib/request/request-transformer.ts +++ b/lib/request/request-transformer.ts @@ -304,6 +304,7 @@ export function getReasoningConfig( const defaultEffort = profile.defaultReasoningEffort; const requestedEffort = userConfig.reasoningEffort ?? defaultEffort; const effort = coerceReasoningEffort( + profile.normalizedModel, requestedEffort, profile.supportedReasoningEfforts, defaultEffort, @@ -330,6 +331,7 @@ const REASONING_FALLBACKS: Record< } as const; function coerceReasoningEffort( + modelName: string, effort: ModelReasoningEffort, supportedEfforts: readonly ModelReasoningEffort[], defaultEffort: ModelReasoningEffort, @@ -341,10 +343,20 @@ function coerceReasoningEffort( const fallbackOrder = REASONING_FALLBACKS[effort] ?? [defaultEffort]; for (const candidate of fallbackOrder) { if (supportedEfforts.includes(candidate)) { + logWarn("Coercing unsupported reasoning effort for model", { + model: modelName, + requestedEffort: effort, + effectiveEffort: candidate, + }); return candidate; } } + logWarn("Falling back to default reasoning effort for model", { + model: modelName, + requestedEffort: effort, + effectiveEffort: defaultEffort, + }); return defaultEffort; } diff --git a/test/config.test.ts b/test/config.test.ts index cec3149d..c0e3fcbe 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -1,9 +1,10 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { applyFastSessionDefaults, getModelConfig, getReasoningConfig, } from '../lib/request/request-transformer.js'; +import * as logger from '../lib/logger.js'; import type { UserConfig } from '../lib/types.js'; describe('Configuration Parsing', () => { @@ -97,6 +98,28 @@ describe('Configuration Parsing', () => { expect(nanoReasoning.summary).toBe('auto'); }); + it('should warn when a lightweight model reasoning request is coerced', () => { + const warnSpy = vi.spyOn(logger, 'logWarn').mockImplementation(() => {}); + + try { + const miniReasoning = getReasoningConfig('gpt-5-mini', { + reasoningEffort: 'high', + }); + + expect(miniReasoning.effort).toBe('medium'); + expect(warnSpy).toHaveBeenCalledWith( + 'Coercing unsupported reasoning effort for model', + expect.objectContaining({ + model: 'gpt-5-mini', + requestedEffort: 'high', + effectiveEffort: 'medium', + }), + ); + } finally { + warnSpy.mockRestore(); + } + }); + it('should normalize "minimal" to "low" for gpt-5-codex', () => { const codexMinimalConfig = { reasoningEffort: 'minimal' as const }; const codexMinimalReasoning = getReasoningConfig('gpt-5-codex', codexMinimalConfig); From 740ab50d7d3137cfb562d26900b8666d31a749fc Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:42:24 +0800 Subject: [PATCH 335/376] fix: preserve live probe fallback reporting --- lib/codex-manager/commands/fix.ts | 40 ++++++----- test/codex-manager-fix-command.test.ts | 95 ++++++++++++++++++++------ 2 files changed, 97 insertions(+), 38 deletions(-) diff --git a/lib/codex-manager/commands/fix.ts b/lib/codex-manager/commands/fix.ts index 8cabdd17..74d89cb3 100644 --- a/lib/codex-manager/commands/fix.ts +++ b/lib/codex-manager/commands/fix.ts @@ -228,7 +228,6 @@ export async function runFixCommand( } if (deps.hasUsableAccessToken(account, now)) { - let needsRefresh = false; if (options.live) { const currentAccessToken = account.accessToken; const probeAccountId = currentAccessToken @@ -259,26 +258,33 @@ export async function runFixCommand( : "live session OK", }); continue; - } catch { - needsRefresh = true; + } catch (error) { + const message = deps.normalizeFailureDetail( + error instanceof Error ? error.message : String(error), + undefined, + ); + reports.push({ + index: i, + label, + outcome: "warning-soft-failure", + message: `live probe failed (${message}), trying refresh fallback`, + }); } } } - if (!needsRefresh) { - const refreshWarning = deps.hasLikelyInvalidRefreshToken( - account.refreshToken, - ) - ? " (refresh token looks stale; re-login recommended)" - : ""; - reports.push({ - index: i, - label, - outcome: "healthy", - message: `access token still valid${refreshWarning}`, - }); - continue; - } + const refreshWarning = deps.hasLikelyInvalidRefreshToken( + account.refreshToken, + ) + ? " (refresh token looks stale; re-login recommended)" + : ""; + reports.push({ + index: i, + label, + outcome: "healthy", + message: `access token still valid${refreshWarning}`, + }); + continue; } const refreshResult = await deps.queuedRefresh(account.refreshToken); diff --git a/test/codex-manager-fix-command.test.ts b/test/codex-manager-fix-command.test.ts index 33beedc2..f86e0bd7 100644 --- a/test/codex-manager-fix-command.test.ts +++ b/test/codex-manager-fix-command.test.ts @@ -103,7 +103,7 @@ describe("runFixCommand", () => { }); }); - it("falls back to refresh when live probe fails for a usable access token", async () => { + it("keeps usable access tokens healthy when the live probe fails", async () => { const storage = createStorage(); storage.accounts.push({ email: "fix@example.com", @@ -128,42 +128,31 @@ describe("runFixCommand", () => { })), fetchCodexQuotaSnapshot: vi .fn() - .mockRejectedValueOnce(new Error("probe exploded")) - .mockResolvedValueOnce({ - status: 200, - model: "gpt-5-codex", - primary: {}, - secondary: {}, - }), + .mockRejectedValueOnce(new Error("probe exploded")), extractAccountId: vi.fn((accessToken?: string) => accessToken ? "acc_1" : undefined, ), - queuedRefresh: vi.fn(async () => ({ - type: "success", - access: "access-refreshed", - refresh: "refresh-refreshed", - expires: 8_000, - idToken: "id-token", - })), }); const result = await runFixCommand([], deps); expect(result).toBe(0); - expect(deps.queuedRefresh).toHaveBeenCalledTimes(1); + expect(deps.queuedRefresh).not.toHaveBeenCalled(); const payload = JSON.parse(String((deps.logInfo as ReturnType).mock.calls[0]?.[0])) as { reports: Array<{ outcome: string; message: string }>; }; - expect(payload.reports).toHaveLength(1); + expect(payload.reports).toHaveLength(2); expect(payload.reports[0]).toMatchObject({ + outcome: "warning-soft-failure", + message: "live probe failed (probe exploded), trying refresh fallback", + }); + expect(payload.reports[1]).toMatchObject({ outcome: "healthy", + message: "access token still valid", }); - expect(payload.reports[0]?.message).toContain( - "refresh + live probe succeeded", - ); }); - it("does not persist quota cache during dry-run", async () => { + it("does not persist accounts or quota cache during dry-run json mode", async () => { const storage = createStorage(); storage.accounts.push({ email: "fix@example.com", @@ -200,6 +189,70 @@ describe("runFixCommand", () => { const result = await runFixCommand([], deps); expect(result).toBe(0); + expect(deps.saveAccounts).not.toHaveBeenCalled(); expect(deps.saveQuotaCache).not.toHaveBeenCalled(); }); + + it("renders preview output without saving in human mode", async () => { + const storage = createStorage(); + storage.accounts.push({ + email: "fix@example.com", + refreshToken: "refresh-token", + accessToken: "access-token", + accountId: "acc_1", + expiresAt: 900, + addedAt: 0, + lastUsed: 0, + enabled: true, + }); + const deps = createDeps({ + loadAccounts: vi.fn(async () => structuredClone(storage)), + parseFixArgs: vi.fn(() => ({ + ok: true as const, + options: { + dryRun: true, + json: false, + live: false, + model: "gpt-5-codex", + } satisfies FixCliOptions, + })), + hasUsableAccessToken: vi.fn(() => false), + queuedRefresh: vi.fn(async () => ({ + type: "success", + access: "access-refreshed", + refresh: "refresh-refreshed", + expires: 8_000, + idToken: "id-token", + })), + }); + + const result = await runFixCommand([], deps); + + expect(result).toBe(0); + expect(deps.stylePromptText).toHaveBeenCalledWith( + "Auto-fix scan (preview)", + "accent", + ); + expect(deps.formatResultSummary).toHaveBeenCalledWith([ + { text: "1 working", tone: "success" }, + { text: "0 disabled", tone: "muted" }, + { text: "0 warnings", tone: "muted" }, + { text: "0 already disabled", tone: "muted" }, + ]); + expect(deps.styleAccountDetailText).toHaveBeenCalledWith( + "refresh succeeded", + "muted", + ); + expect(deps.saveAccounts).not.toHaveBeenCalled(); + expect(deps.saveQuotaCache).not.toHaveBeenCalled(); + + const infoLines = (deps.logInfo as ReturnType).mock.calls.map( + ([message]) => String(message), + ); + expect(infoLines).toContain("Auto-fix scan (preview)"); + expect(infoLines).toContain( + "1 working | 0 disabled | 0 warnings | 0 already disabled", + ); + expect(infoLines).toContain("\nPreview only: no changes were saved."); + }); }); From a88225c6ce3921f83c6b5537aadc4c38168d488f Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 20:49:25 +0800 Subject: [PATCH 336/376] narrow update-only session response id helper --- lib/session-affinity.ts | 8 +++++++- test/session-affinity.test.ts | 10 +++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/lib/session-affinity.ts b/lib/session-affinity.ts index 60510a9b..02d77854 100644 --- a/lib/session-affinity.ts +++ b/lib/session-affinity.ts @@ -92,7 +92,13 @@ export class SessionAffinityStore { return lastResponseId || null; } - rememberLastResponseId( + /** + * Update the last response id for an existing live session. + * + * This method does not create a new affinity entry; callers that need to + * upsert continuation state should use `rememberWithResponseId`. + */ + updateLastResponseId( sessionKey: string | null | undefined, responseId: string | null | undefined, now = Date.now(), diff --git a/test/session-affinity.test.ts b/test/session-affinity.test.ts index e9b09d97..1bf1ae73 100644 --- a/test/session-affinity.test.ts +++ b/test/session-affinity.test.ts @@ -114,10 +114,10 @@ describe("SessionAffinityStore", () => { expect(store.getPreferredAccountIndex("s2", 2_001)).toBe(1); }); - it("stores and retrieves the last response id for a live session", () => { + it("updates and retrieves the last response id for a live session", () => { const store = new SessionAffinityStore({ ttlMs: 10_000, maxEntries: 4 }); store.remember("session-a", 1, 1_000); - store.rememberLastResponseId("session-a", "resp_123", 2_000); + store.updateLastResponseId("session-a", "resp_123", 2_000); expect(store.getLastResponseId("session-a", 2_500)).toBe("resp_123"); expect(store.getPreferredAccountIndex("session-a", 2_500)).toBe(1); @@ -125,11 +125,11 @@ describe("SessionAffinityStore", () => { it("does not persist response ids for missing or expired sessions", () => { const store = new SessionAffinityStore({ ttlMs: 1_000, maxEntries: 4 }); - store.rememberLastResponseId("missing", "resp_missing", 1_000); + store.updateLastResponseId("missing", "resp_missing", 1_000); expect(store.getLastResponseId("missing", 1_500)).toBeNull(); store.remember("session-a", 1, 1_000); - store.rememberLastResponseId("session-a", "resp_123", 2_500); + store.updateLastResponseId("session-a", "resp_123", 2_500); expect(store.getLastResponseId("session-a", 2_500)).toBeNull(); expect(store.size()).toBe(0); }); @@ -137,7 +137,7 @@ describe("SessionAffinityStore", () => { it("preserves response id when account index is updated via remember()", () => { const store = new SessionAffinityStore({ ttlMs: 10_000, maxEntries: 4 }); store.remember("session-a", 1, 1_000); - store.rememberLastResponseId("session-a", "resp_123", 2_000); + store.updateLastResponseId("session-a", "resp_123", 2_000); store.remember("session-a", 2, 3_000); expect(store.getLastResponseId("session-a", 3_500)).toBe("resp_123"); From ea2c024d4e77d22e91d6de746fabf7bb22876150 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 20:50:16 +0800 Subject: [PATCH 337/376] refactor: reuse session response id helper --- index.ts | 5 ++++- lib/session-affinity.ts | 24 ------------------------ 2 files changed, 4 insertions(+), 25 deletions(-) diff --git a/index.ts b/index.ts index 235d9149..13c03abb 100644 --- a/index.ts +++ b/index.ts @@ -2477,9 +2477,12 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { const successResponse = await handleSuccessResponse(responseForSuccess, isStreaming, { onResponseId: (responseId) => { if (!responseContinuationEnabled) return; - sessionAffinityStore?.rememberWithResponseId( + sessionAffinityStore?.remember( sessionAffinityKey, successAccountForResponse.index, + ); + sessionAffinityStore?.rememberLastResponseId( + sessionAffinityKey, responseId, ); storedResponseIdForSuccess = true; diff --git a/lib/session-affinity.ts b/lib/session-affinity.ts index 02d77854..9a90950f 100644 --- a/lib/session-affinity.ts +++ b/lib/session-affinity.ts @@ -122,30 +122,6 @@ export class SessionAffinityStore { }); } - rememberWithResponseId( - sessionKey: string | null | undefined, - accountIndex: number, - responseId: string | null | undefined, - now = Date.now(), - ): void { - const key = normalizeSessionKey(sessionKey); - const normalizedResponseId = typeof responseId === "string" ? responseId.trim() : ""; - if (!key || !normalizedResponseId) return; - if (!Number.isFinite(accountIndex) || accountIndex < 0) return; - - const entry = this.entries.get(key); - if (entry?.expiresAt !== undefined && entry.expiresAt <= now) { - this.entries.delete(key); - } - - this.setEntry(key, { - accountIndex, - expiresAt: now + this.ttlMs, - lastResponseId: normalizedResponseId, - updatedAt: now, - }); - } - forgetSession(sessionKey: string | null | undefined): void { const key = normalizeSessionKey(sessionKey); if (!key) return; From 360fe8c5747a96134f6d9897f93c54fa492aeca9 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 20:58:07 +0800 Subject: [PATCH 338/376] test: cover concurrent auth best live refresh writes --- lib/codex-manager/auth-commands.ts | 6 ++- test/codex-manager-auth-commands.test.ts | 65 ++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/lib/codex-manager/auth-commands.ts b/lib/codex-manager/auth-commands.ts index 26b9643d..1ef4640c 100644 --- a/lib/codex-manager/auth-commands.ts +++ b/lib/codex-manager/auth-commands.ts @@ -289,8 +289,10 @@ export async function runSwitch( /** * `codex auth best` still follows the monolith's single-writer storage pattern. - * Callers should keep concurrent CLI dispatches serialized while the live probe - * path mutates refreshed tokens before persisting them back to disk. + * `saveAccounts` already serializes in-process writes and `queuedRefresh` + * deduplicates refresh work, but separate CLI processes can still overlap while + * the live probe path mutates refreshed tokens before persisting them back to + * disk. Callers should keep concurrent CLI dispatches serialized. */ export async function runBest( args: string[], diff --git a/test/codex-manager-auth-commands.test.ts b/test/codex-manager-auth-commands.test.ts index 91b86e7a..21af0207 100644 --- a/test/codex-manager-auth-commands.test.ts +++ b/test/codex-manager-auth-commands.test.ts @@ -539,6 +539,71 @@ describe("codex-manager auth command helpers", () => { ); }); + it("keeps concurrent runBest live refresh writes consistent per snapshot", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + extractAccountEmailMock.mockReturnValue("fresh@example.com"); + const baselineStorage = createStorage([ + { + email: "stale@example.com", + refreshToken: "stale-refresh-token", + accessToken: "stale-access-token", + accountId: "acct-stale", + expiresAt: Date.now() - 1, + addedAt: 1, + lastUsed: 1, + enabled: true, + }, + ]); + loadAccountsMock.mockImplementation( + async () => structuredClone(baselineStorage), + ); + + let releaseFirstSave: (() => void) | undefined; + const firstSaveReleased = new Promise((resolve) => { + releaseFirstSave = resolve; + }); + const persistedSnapshots: AccountStorageV3[] = []; + let saveCallCount = 0; + saveAccountsMock.mockImplementation(async (storage: AccountStorageV3) => { + persistedSnapshots.push(structuredClone(storage)); + saveCallCount += 1; + if (saveCallCount === 1) { + await Promise.resolve(); + await firstSaveReleased; + return; + } + if (saveCallCount === 2) { + releaseFirstSave?.(); + } + }); + + const helpers = createHelpers({ + hasUsableAccessToken: vi.fn(() => false), + }); + + const [firstResult, secondResult] = await Promise.all([ + runBest(["--live", "--json"], helpers), + runBest(["--live", "--json"], helpers), + ]); + + expect(firstResult).toBe(0); + expect(secondResult).toBe(0); + expect(saveAccountsMock).toHaveBeenCalledTimes(2); + expect(persistedSnapshots).toHaveLength(2); + for (const snapshot of persistedSnapshots) { + expect(snapshot.activeIndex).toBe(0); + expect(snapshot.accounts[0]).toMatchObject({ + email: "fresh@example.com", + refreshToken: "fresh-refresh-token", + accessToken: "fresh-access-token", + accountId: "acct-refreshed", + accountIdSource: "token", + }); + } + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledTimes(2); + expect(logSpy).toHaveBeenCalledTimes(2); + }); + it("restores a backup through the extracted login flow and clamps family indices", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); const restoredStorage = createStorage([ From 276201285c51a142eeb19da78a73f88503199d63 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 21:04:54 +0800 Subject: [PATCH 339/376] Serialize live auth-best probe writes --- lib/codex-manager/auth-commands.ts | 422 +++++++++++++---------- test/codex-manager-auth-commands.test.ts | 107 +++--- 2 files changed, 289 insertions(+), 240 deletions(-) diff --git a/lib/codex-manager/auth-commands.ts b/lib/codex-manager/auth-commands.ts index 1ef4640c..5fbf138d 100644 --- a/lib/codex-manager/auth-commands.ts +++ b/lib/codex-manager/auth-commands.ts @@ -21,6 +21,7 @@ import { queuedRefresh } from "../refresh-queue.js"; import { getNamedBackups, formatStorageErrorHint, + getStoragePath, loadAccounts, loadFlaggedAccounts, restoreAccountsFromBackup, @@ -34,6 +35,7 @@ import { import type { AccountIdSource, TokenFailure, TokenResult } from "../types.js"; import { setCodexCliActiveSelection } from "../codex-cli/writer.js"; import { MODEL_FAMILIES, type ModelFamily } from "../prompts/codex.js"; +import { RefreshLeaseCoordinator } from "../refresh-lease.js"; import { UI_COPY } from "../ui/copy.js"; import { confirm } from "../ui/confirm.js"; import { @@ -58,6 +60,10 @@ type OAuthSignInMode = "browser" | "manual" | "restore-backup" | "cancel"; type BackupRestoreMode = "latest" | "manual" | "back"; type LoginMenuResult = Awaited>; type HealthCheckOptions = { forceRefresh?: boolean; liveProbe?: boolean }; +type SerializedLiveBestQueueEntry = { tail: Promise }; + +const liveBestLeaseCoordinator = RefreshLeaseCoordinator.fromEnvironment(); +const serializedLiveBestByStorage = new Map(); export interface AuthCommandHelpers { resolveActiveIndex: ( @@ -134,6 +140,44 @@ export interface AuthLoginCommandDeps extends AuthCommandHelpers { }; } +async function withSerializedBestLiveRun( + storagePath: string, + action: () => Promise, +): Promise { + const queueEntry = + serializedLiveBestByStorage.get(storagePath) ?? { tail: Promise.resolve() }; + serializedLiveBestByStorage.set(storagePath, queueEntry); + + const waitForPrior = queueEntry.tail.catch(() => undefined); + let releaseQueue = (): void => undefined; + const currentTail = new Promise((resolve) => { + releaseQueue = resolve; + }); + queueEntry.tail = currentTail; + + await waitForPrior; + + let lease: Awaited> | null = null; + try { + lease = await liveBestLeaseCoordinator.acquire(`codex-auth-best-live:${storagePath}`); + return await action(); + } finally { + try { + if (lease) { + await lease.release(); + } + } finally { + releaseQueue(); + if ( + serializedLiveBestByStorage.get(storagePath) === queueEntry && + queueEntry.tail === currentTail + ) { + serializedLiveBestByStorage.delete(storagePath); + } + } + } +} + function clampPreservedActiveIndexByFamily( storage: AccountStorageV3, targetIndex: number, @@ -287,13 +331,6 @@ export async function runSwitch( return 0; } -/** - * `codex auth best` still follows the monolith's single-writer storage pattern. - * `saveAccounts` already serializes in-process writes and `queuedRefresh` - * deduplicates refresh work, but separate CLI processes can still overlap while - * the live probe path mutates refreshed tokens before persisting them back to - * disk. Callers should keep concurrent CLI dispatches serialized. - */ export async function runBest( args: string[], helpers: AuthCommandHelpers, @@ -316,217 +353,228 @@ export async function runBest( } setStoragePath(null); - const storage = await loadAccounts(); - if (!storage || storage.accounts.length === 0) { - if (options.json) { - console.log(JSON.stringify({ error: "No accounts configured." }, null, 2)); - } else { - console.log("No accounts configured."); + const execute = async (): Promise => { + const storage = await loadAccounts(); + if (!storage || storage.accounts.length === 0) { + if (options.json) { + console.log(JSON.stringify({ error: "No accounts configured." }, null, 2)); + } else { + console.log("No accounts configured."); + } + return 1; } - return 1; - } - const now = Date.now(); - const refreshFailures = new Map(); - const liveQuotaByIndex = new Map>>(); - const probeIdTokenByIndex = new Map(); - const probeRefreshedIndices = new Set(); - const probeErrors: string[] = []; - let changed = false; - - const printProbeNotes = (): void => { - if (probeErrors.length === 0) return; - console.log(`Live check notes (${probeErrors.length}):`); - for (const error of probeErrors) { - console.log(` - ${error}`); - } - }; + const now = Date.now(); + const refreshFailures = new Map(); + const liveQuotaByIndex = new Map>>(); + const probeIdTokenByIndex = new Map(); + const probeRefreshedIndices = new Set(); + const probeErrors: string[] = []; + let changed = false; + + const printProbeNotes = (): void => { + if (probeErrors.length === 0) return; + console.log(`Live check notes (${probeErrors.length}):`); + for (const error of probeErrors) { + console.log(` - ${error}`); + } + }; - const persistProbeChangesIfNeeded = async (): Promise => { - if (!changed) return; - await saveAccounts(storage); - changed = false; - }; + const persistProbeChangesIfNeeded = async (): Promise => { + if (!changed) return; + await saveAccounts(storage); + changed = false; + }; - for (let i = 0; i < storage.accounts.length; i += 1) { - const account = storage.accounts[i]; - if (!account || !options.live) continue; - if (account.enabled === false) continue; - - let probeAccessToken = account.accessToken; - let probeAccountId = account.accountId ?? extractAccountId(account.accessToken); - if (!helpers.hasUsableAccessToken(account, now)) { - const refreshResult = await queuedRefresh(account.refreshToken); - if (refreshResult.type !== "success") { - refreshFailures.set(i, { - ...refreshResult, - message: helpers.normalizeFailureDetail(refreshResult.message, refreshResult.reason), - }); - continue; - } + for (let i = 0; i < storage.accounts.length; i += 1) { + const account = storage.accounts[i]; + if (!account || !options.live) continue; + if (account.enabled === false) continue; + + let probeAccessToken = account.accessToken; + let probeAccountId = account.accountId ?? extractAccountId(account.accessToken); + if (!helpers.hasUsableAccessToken(account, now)) { + const refreshResult = await queuedRefresh(account.refreshToken); + if (refreshResult.type !== "success") { + refreshFailures.set(i, { + ...refreshResult, + message: helpers.normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ), + }); + continue; + } - const refreshedEmail = sanitizeEmail( - extractAccountEmail(refreshResult.access, refreshResult.idToken), - ); - const refreshedAccountId = extractAccountId(refreshResult.access); + const refreshedEmail = sanitizeEmail( + extractAccountEmail(refreshResult.access, refreshResult.idToken), + ); + const refreshedAccountId = extractAccountId(refreshResult.access); - if (account.refreshToken !== refreshResult.refresh) { - account.refreshToken = refreshResult.refresh; - changed = true; - } - if (account.accessToken !== refreshResult.access) { - account.accessToken = refreshResult.access; - changed = true; - } - if (account.expiresAt !== refreshResult.expires) { - account.expiresAt = refreshResult.expires; - changed = true; - } - if (refreshedEmail && refreshedEmail !== account.email) { - account.email = refreshedEmail; - changed = true; - } - if (refreshedAccountId && refreshedAccountId !== account.accountId) { - account.accountId = refreshedAccountId; - account.accountIdSource = "token"; - changed = true; - } - if (refreshResult.idToken) { - probeIdTokenByIndex.set(i, refreshResult.idToken); - } - probeRefreshedIndices.add(i); + if (account.refreshToken !== refreshResult.refresh) { + account.refreshToken = refreshResult.refresh; + changed = true; + } + if (account.accessToken !== refreshResult.access) { + account.accessToken = refreshResult.access; + changed = true; + } + if (account.expiresAt !== refreshResult.expires) { + account.expiresAt = refreshResult.expires; + changed = true; + } + if (refreshedEmail && refreshedEmail !== account.email) { + account.email = refreshedEmail; + changed = true; + } + if (refreshedAccountId && refreshedAccountId !== account.accountId) { + account.accountId = refreshedAccountId; + account.accountIdSource = "token"; + changed = true; + } + if (refreshResult.idToken) { + probeIdTokenByIndex.set(i, refreshResult.idToken); + } + probeRefreshedIndices.add(i); - probeAccessToken = account.accessToken; - probeAccountId = account.accountId ?? refreshedAccountId; - } + probeAccessToken = account.accessToken; + probeAccountId = account.accountId ?? refreshedAccountId; + } - if (!probeAccessToken || !probeAccountId) { - probeErrors.push(`${formatAccountLabel(account, i)}: missing accountId for live probe`); - continue; - } + if (!probeAccessToken || !probeAccountId) { + probeErrors.push(`${formatAccountLabel(account, i)}: missing accountId for live probe`); + continue; + } - try { - const liveQuota = await fetchCodexQuotaSnapshot({ - accountId: probeAccountId, - accessToken: probeAccessToken, - model: options.model, - }); - liveQuotaByIndex.set(i, liveQuota); - } catch (error) { - const message = helpers.normalizeFailureDetail( - error instanceof Error ? error.message : String(error), - undefined, - ); - probeErrors.push(`${formatAccountLabel(account, i)}: ${message}`); + try { + const liveQuota = await fetchCodexQuotaSnapshot({ + accountId: probeAccountId, + accessToken: probeAccessToken, + model: options.model, + }); + liveQuotaByIndex.set(i, liveQuota); + } catch (error) { + const message = helpers.normalizeFailureDetail( + error instanceof Error ? error.message : String(error), + undefined, + ); + probeErrors.push(`${formatAccountLabel(account, i)}: ${message}`); + } } - } - const forecastInputs = storage.accounts.map((account, index) => ({ - index, - account, - isCurrent: index === helpers.resolveActiveIndex(storage, "codex"), - now, - refreshFailure: refreshFailures.get(index), - liveQuota: liveQuotaByIndex.get(index), - })); + const forecastInputs = storage.accounts.map((account, index) => ({ + index, + account, + isCurrent: index === helpers.resolveActiveIndex(storage, "codex"), + now, + refreshFailure: refreshFailures.get(index), + liveQuota: liveQuotaByIndex.get(index), + })); - const forecastResults = evaluateForecastAccounts(forecastInputs); - const recommendation = recommendForecastAccount(forecastResults); + const forecastResults = evaluateForecastAccounts(forecastInputs); + const recommendation = recommendForecastAccount(forecastResults); - if (recommendation.recommendedIndex === null) { - await persistProbeChangesIfNeeded(); - if (options.json) { - console.log(JSON.stringify({ - error: recommendation.reason, - ...(probeErrors.length > 0 ? { probeErrors } : {}), - }, null, 2)); - } else { - console.log(`No best account available: ${recommendation.reason}`); - printProbeNotes(); - } - return 1; - } - - const bestIndex = recommendation.recommendedIndex; - const bestAccount = storage.accounts[bestIndex]; - if (!bestAccount) { - await persistProbeChangesIfNeeded(); - if (options.json) { - console.log(JSON.stringify({ error: "Best account not found." }, null, 2)); - } else { - console.log("Best account not found."); + if (recommendation.recommendedIndex === null) { + await persistProbeChangesIfNeeded(); + if (options.json) { + console.log(JSON.stringify({ + error: recommendation.reason, + ...(probeErrors.length > 0 ? { probeErrors } : {}), + }, null, 2)); + } else { + console.log(`No best account available: ${recommendation.reason}`); + printProbeNotes(); + } + return 1; } - return 1; - } - const currentIndex = helpers.resolveActiveIndex(storage, "codex"); - if (currentIndex === bestIndex) { - const shouldSyncCurrentBest = - probeRefreshedIndices.has(bestIndex) || probeIdTokenByIndex.has(bestIndex); - let alreadyBestSynced: boolean | undefined; - if (changed) { - bestAccount.lastUsed = now; + const bestIndex = recommendation.recommendedIndex; + const bestAccount = storage.accounts[bestIndex]; + if (!bestAccount) { await persistProbeChangesIfNeeded(); + if (options.json) { + console.log(JSON.stringify({ error: "Best account not found." }, null, 2)); + } else { + console.log("Best account not found."); + } + return 1; } - if (shouldSyncCurrentBest) { - alreadyBestSynced = await setCodexCliActiveSelection({ - accountId: bestAccount.accountId, - email: bestAccount.email, - accessToken: bestAccount.accessToken, - refreshToken: bestAccount.refreshToken, - expiresAt: bestAccount.expiresAt, - ...(probeIdTokenByIndex.has(bestIndex) - ? { idToken: probeIdTokenByIndex.get(bestIndex) } - : {}), - }); - if (!alreadyBestSynced && !options.json) { - console.warn("Codex auth sync did not complete. Multi-auth routing will still use this account."); + + const currentIndex = helpers.resolveActiveIndex(storage, "codex"); + if (currentIndex === bestIndex) { + const shouldSyncCurrentBest = + probeRefreshedIndices.has(bestIndex) || probeIdTokenByIndex.has(bestIndex); + let alreadyBestSynced: boolean | undefined; + if (changed) { + bestAccount.lastUsed = now; + await persistProbeChangesIfNeeded(); + } + if (shouldSyncCurrentBest) { + alreadyBestSynced = await setCodexCliActiveSelection({ + accountId: bestAccount.accountId, + email: bestAccount.email, + accessToken: bestAccount.accessToken, + refreshToken: bestAccount.refreshToken, + expiresAt: bestAccount.expiresAt, + ...(probeIdTokenByIndex.has(bestIndex) + ? { idToken: probeIdTokenByIndex.get(bestIndex) } + : {}), + }); + if (!alreadyBestSynced && !options.json) { + console.warn("Codex auth sync did not complete. Multi-auth routing will still use this account."); + } } + if (options.json) { + console.log(JSON.stringify({ + message: `Already on best account: ${formatAccountLabel(bestAccount, bestIndex)}`, + accountIndex: bestIndex + 1, + reason: recommendation.reason, + ...(alreadyBestSynced !== undefined ? { synced: alreadyBestSynced } : {}), + ...(probeErrors.length > 0 ? { probeErrors } : {}), + }, null, 2)); + } else { + console.log(`Already on best account ${bestIndex + 1}: ${formatAccountLabel(bestAccount, bestIndex)}`); + console.log(`Reason: ${recommendation.reason}`); + printProbeNotes(); + } + return 0; } + + const parsed = bestIndex + 1; + const { synced, wasDisabled } = await persistAndSyncSelectedAccount({ + storage, + targetIndex: bestIndex, + parsed, + switchReason: "best", + initialSyncIdToken: probeIdTokenByIndex.get(bestIndex), + helpers, + }); + if (options.json) { console.log(JSON.stringify({ - message: `Already on best account: ${formatAccountLabel(bestAccount, bestIndex)}`, - accountIndex: bestIndex + 1, + message: `Switched to best account: ${formatAccountLabel(bestAccount, bestIndex)}`, + accountIndex: parsed, reason: recommendation.reason, - ...(alreadyBestSynced !== undefined ? { synced: alreadyBestSynced } : {}), + synced, + wasDisabled, ...(probeErrors.length > 0 ? { probeErrors } : {}), }, null, 2)); } else { - console.log(`Already on best account ${bestIndex + 1}: ${formatAccountLabel(bestAccount, bestIndex)}`); + console.log(`Switched to best account ${parsed}: ${formatAccountLabel(bestAccount, bestIndex)}${wasDisabled ? " (re-enabled)" : ""}`); console.log(`Reason: ${recommendation.reason}`); printProbeNotes(); + if (!synced) { + console.warn("Codex auth sync did not complete. Multi-auth routing will still use this account."); + } } return 0; - } - - const parsed = bestIndex + 1; - const { synced, wasDisabled } = await persistAndSyncSelectedAccount({ - storage, - targetIndex: bestIndex, - parsed, - switchReason: "best", - initialSyncIdToken: probeIdTokenByIndex.get(bestIndex), - helpers, - }); + }; - if (options.json) { - console.log(JSON.stringify({ - message: `Switched to best account: ${formatAccountLabel(bestAccount, bestIndex)}`, - accountIndex: parsed, - reason: recommendation.reason, - synced, - wasDisabled, - ...(probeErrors.length > 0 ? { probeErrors } : {}), - }, null, 2)); - } else { - console.log(`Switched to best account ${parsed}: ${formatAccountLabel(bestAccount, bestIndex)}${wasDisabled ? " (re-enabled)" : ""}`); - console.log(`Reason: ${recommendation.reason}`); - printProbeNotes(); - if (!synced) { - console.warn("Codex auth sync did not complete. Multi-auth routing will still use this account."); - } + if (options.live) { + return withSerializedBestLiveRun(getStoragePath(), execute); } - return 0; + + return execute(); } export async function runAuthLogin( diff --git a/test/codex-manager-auth-commands.test.ts b/test/codex-manager-auth-commands.test.ts index 21af0207..05d170de 100644 --- a/test/codex-manager-auth-commands.test.ts +++ b/test/codex-manager-auth-commands.test.ts @@ -539,69 +539,70 @@ describe("codex-manager auth command helpers", () => { ); }); - it("keeps concurrent runBest live refresh writes consistent per snapshot", async () => { + it("serializes concurrent runBest live refresh writes per storage file", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); - extractAccountEmailMock.mockReturnValue("fresh@example.com"); - const baselineStorage = createStorage([ - { - email: "stale@example.com", - refreshToken: "stale-refresh-token", - accessToken: "stale-access-token", - accountId: "acct-stale", - expiresAt: Date.now() - 1, - addedAt: 1, - lastUsed: 1, - enabled: true, - }, - ]); - loadAccountsMock.mockImplementation( - async () => structuredClone(baselineStorage), - ); + const helpers = createHelpers({ + hasUsableAccessToken: vi.fn(() => false), + }); + const makeExpiredStorage = (): AccountStorageV3 => + createStorage([ + { + email: "live@example.com", + refreshToken: "refresh-token-live", + accessToken: "stale-access-token", + accountId: "acct-live", + expiresAt: Date.now() - 1, + addedAt: 1, + lastUsed: 1, + enabled: true, + }, + ]); + loadAccountsMock.mockImplementation(async () => makeExpiredStorage()); - let releaseFirstSave: (() => void) | undefined; - const firstSaveReleased = new Promise((resolve) => { - releaseFirstSave = resolve; + let resolveFirstSaveStarted = (): void => undefined; + const firstSaveStarted = new Promise((resolve) => { + resolveFirstSaveStarted = resolve; }); - const persistedSnapshots: AccountStorageV3[] = []; - let saveCallCount = 0; - saveAccountsMock.mockImplementation(async (storage: AccountStorageV3) => { - persistedSnapshots.push(structuredClone(storage)); - saveCallCount += 1; - if (saveCallCount === 1) { - await Promise.resolve(); - await firstSaveReleased; - return; - } - if (saveCallCount === 2) { - releaseFirstSave?.(); + let resolveFirstSave = (): void => undefined; + const firstSaveBlocked = new Promise((resolve) => { + resolveFirstSave = resolve; + }); + let activeSaves = 0; + let maxActiveSaves = 0; + let startedSaves = 0; + saveAccountsMock.mockImplementation(async () => { + startedSaves += 1; + activeSaves += 1; + maxActiveSaves = Math.max(maxActiveSaves, activeSaves); + if (startedSaves === 1) { + resolveFirstSaveStarted(); + await firstSaveBlocked; } + activeSaves -= 1; }); - const helpers = createHelpers({ - hasUsableAccessToken: vi.fn(() => false), - }); + const firstRun = runBest(["--live"], helpers); + await firstSaveStarted; - const [firstResult, secondResult] = await Promise.all([ - runBest(["--live", "--json"], helpers), - runBest(["--live", "--json"], helpers), - ]); + const secondRun = runBest(["--live"], helpers); + await Promise.resolve(); - expect(firstResult).toBe(0); - expect(secondResult).toBe(0); + expect(loadAccountsMock).toHaveBeenCalledTimes(1); + expect(queuedRefreshMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + + resolveFirstSave(); + + await expect(Promise.all([firstRun, secondRun])).resolves.toEqual([0, 0]); + + expect(loadAccountsMock).toHaveBeenCalledTimes(2); + expect(queuedRefreshMock).toHaveBeenCalledTimes(2); expect(saveAccountsMock).toHaveBeenCalledTimes(2); - expect(persistedSnapshots).toHaveLength(2); - for (const snapshot of persistedSnapshots) { - expect(snapshot.activeIndex).toBe(0); - expect(snapshot.accounts[0]).toMatchObject({ - email: "fresh@example.com", - refreshToken: "fresh-refresh-token", - accessToken: "fresh-access-token", - accountId: "acct-refreshed", - accountIdSource: "token", - }); - } expect(setCodexCliActiveSelectionMock).toHaveBeenCalledTimes(2); - expect(logSpy).toHaveBeenCalledTimes(2); + expect(maxActiveSaves).toBe(1); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("Already on best account 1"), + ); }); it("restores a backup through the extracted login flow and clamps family indices", async () => { From 37b768fb64e5a99331e44717f2405abd8fc7110c Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 16:02:34 +0800 Subject: [PATCH 340/376] enhance responses parser for semantic SSE events --- lib/request/response-handler.ts | 438 ++++++++++++++++++++++++++++++-- test/fetch-helpers.test.ts | 19 ++ test/response-handler.test.ts | 82 ++++++ 3 files changed, 515 insertions(+), 24 deletions(-) diff --git a/lib/request/response-handler.ts b/lib/request/response-handler.ts index 861be8ab..a36f89ae 100644 --- a/lib/request/response-handler.ts +++ b/lib/request/response-handler.ts @@ -1,5 +1,6 @@ import { createLogger, logRequest, LOGGING_ENABLED } from "../logger.js"; import { PLUGIN_NAME } from "../constants.js"; +import { isRecord } from "../utils.js"; import type { SSEEventData } from "../types.js"; @@ -8,6 +9,322 @@ const log = createLogger("response-handler"); const MAX_SSE_SIZE = 10 * 1024 * 1024; // 10MB limit to prevent memory exhaustion const DEFAULT_STREAM_STALL_TIMEOUT_MS = 45_000; +type MutableRecord = Record; + +interface ParsedResponseState { + finalResponse: MutableRecord | null; + lastPhase: string | null; + outputItems: Map; + outputText: Map; + phaseText: Map; + reasoningSummaryText: Map; +} + +function createParsedResponseState(): ParsedResponseState { + return { + finalResponse: null, + lastPhase: null, + outputItems: new Map(), + outputText: new Map(), + phaseText: new Map(), + reasoningSummaryText: new Map(), + }; +} + +function toMutableRecord(value: unknown): MutableRecord | null { + return isRecord(value) ? { ...value } : null; +} + +function getNumberField(record: MutableRecord, key: string): number | null { + const value = record[key]; + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function getStringField(record: MutableRecord, key: string): string | null { + const value = record[key]; + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +function cloneContentArray(content: unknown): MutableRecord[] { + if (!Array.isArray(content)) return []; + return content.filter(isRecord).map((part) => ({ ...part })); +} + +function mergeRecord(base: MutableRecord | null, update: MutableRecord): MutableRecord { + if (!base) return { ...update }; + const merged: MutableRecord = { ...base, ...update }; + if ("content" in update || "content" in base) { + merged.content = cloneContentArray(update.content ?? base.content); + } + return merged; +} + +function makeOutputTextKey(outputIndex: number | null, contentIndex: number | null): string | null { + if (outputIndex === null || contentIndex === null) return null; + return `${outputIndex}:${contentIndex}`; +} + +function makeSummaryKey(outputIndex: number | null, summaryIndex: number | null): string | null { + if (outputIndex === null || summaryIndex === null) return null; + return `${outputIndex}:${summaryIndex}`; +} + +function getPartText(part: unknown): string | null { + if (!isRecord(part)) return null; + const text = getStringField(part, "text"); + if (text) return text; + return null; +} + +function capturePhase( + state: ParsedResponseState, + phase: unknown, + text: string | null = null, +): void { + if (typeof phase !== "string" || phase.trim().length === 0) return; + const normalizedPhase = phase.trim(); + state.lastPhase = normalizedPhase; + if (text && text.length > 0) { + const existing = state.phaseText.get(normalizedPhase) ?? ""; + state.phaseText.set(normalizedPhase, `${existing}${text}`); + } +} + +function upsertOutputItem(state: ParsedResponseState, outputIndex: number | null, item: unknown): void { + if (outputIndex === null || !isRecord(item)) return; + const current = state.outputItems.get(outputIndex) ?? null; + const merged = mergeRecord(current, item); + state.outputItems.set(outputIndex, merged); + capturePhase(state, merged.phase); +} + +function setOutputTextValue( + state: ParsedResponseState, + outputIndex: number | null, + contentIndex: number | null, + text: string | null, + phase: unknown = undefined, +): void { + if (!text) return; + const key = makeOutputTextKey(outputIndex, contentIndex); + if (!key) return; + const existing = state.outputText.get(key) ?? ""; + state.outputText.set(key, text); + const phaseDelta = existing.length > 0 && text.startsWith(existing) + ? text.slice(existing.length) + : existing === text + ? "" + : text; + capturePhase(state, phase, phaseDelta); +} + +function appendOutputTextValue( + state: ParsedResponseState, + outputIndex: number | null, + contentIndex: number | null, + delta: string | null, + phase: unknown = undefined, +): void { + if (!delta) return; + const key = makeOutputTextKey(outputIndex, contentIndex); + if (!key) return; + const existing = state.outputText.get(key) ?? ""; + state.outputText.set(key, `${existing}${delta}`); + capturePhase(state, phase, delta); +} + +function setReasoningSummaryValue( + state: ParsedResponseState, + outputIndex: number | null, + summaryIndex: number | null, + text: string | null, +): void { + if (!text) return; + const key = makeSummaryKey(outputIndex, summaryIndex); + if (!key) return; + state.reasoningSummaryText.set(key, text); +} + +function appendReasoningSummaryValue( + state: ParsedResponseState, + outputIndex: number | null, + summaryIndex: number | null, + delta: string | null, +): void { + if (!delta) return; + const key = makeSummaryKey(outputIndex, summaryIndex); + if (!key) return; + const existing = state.reasoningSummaryText.get(key) ?? ""; + state.reasoningSummaryText.set(key, `${existing}${delta}`); +} + +function ensureOutputItemAtIndex(output: unknown[], index: number): MutableRecord | null { + while (output.length <= index) { + output.push({}); + } + const current = output[index]; + if (!isRecord(current)) { + output[index] = {}; + } + return isRecord(output[index]) ? (output[index] as MutableRecord) : null; +} + +function ensureContentPartAtIndex(item: MutableRecord, index: number): MutableRecord | null { + const content = Array.isArray(item.content) ? [...item.content] : []; + while (content.length <= index) { + content.push({}); + } + const current = content[index]; + if (!isRecord(current)) { + content[index] = {}; + } + item.content = content; + return isRecord(content[index]) ? (content[index] as MutableRecord) : null; +} + +function applyAccumulatedOutputText(response: MutableRecord, state: ParsedResponseState): void { + if (state.outputText.size === 0) return; + const output = Array.isArray(response.output) ? [...response.output] : []; + + for (const [key, text] of state.outputText.entries()) { + const [outputIndexText, contentIndexText] = key.split(":"); + const outputIndex = Number.parseInt(outputIndexText ?? "", 10); + const contentIndex = Number.parseInt(contentIndexText ?? "", 10); + if (!Number.isFinite(outputIndex) || !Number.isFinite(contentIndex)) continue; + const item = ensureOutputItemAtIndex(output, outputIndex); + if (!item) continue; + const part = ensureContentPartAtIndex(item, contentIndex); + if (!part) continue; + if (!getStringField(part, "type")) { + part.type = "output_text"; + } + part.text = text; + } + + if (output.length > 0) { + response.output = output; + } +} + +function mergeOutputItemsIntoResponse(response: MutableRecord, state: ParsedResponseState): void { + if (state.outputItems.size === 0) return; + const output = Array.isArray(response.output) ? [...response.output] : []; + + for (const [outputIndex, item] of state.outputItems.entries()) { + while (output.length <= outputIndex) { + output.push({}); + } + output[outputIndex] = mergeRecord(toMutableRecord(output[outputIndex]), item); + } + + response.output = output; +} + +function collectMessageOutputText(output: unknown[]): string { + return output + .filter(isRecord) + .map((item) => { + if (item.type !== "message") return ""; + const content = Array.isArray(item.content) ? item.content : []; + return content + .filter(isRecord) + .map((part) => { + if (part.type !== "output_text") return ""; + return typeof part.text === "string" ? part.text : ""; + }) + .join(""); + }) + .filter((text) => text.length > 0) + .join(""); +} + +function collectReasoningSummaryText(output: unknown[]): string { + return output + .filter(isRecord) + .map((item) => { + if (item.type !== "reasoning") return ""; + const summary = Array.isArray(item.summary) ? item.summary : []; + return summary + .filter(isRecord) + .map((part) => (typeof part.text === "string" ? part.text : "")) + .filter((text) => text.length > 0) + .join("\n\n"); + }) + .filter((text) => text.length > 0) + .join("\n\n"); +} + +function applyReasoningSummaries(response: MutableRecord, state: ParsedResponseState): void { + if (state.reasoningSummaryText.size === 0) return; + const output = Array.isArray(response.output) ? [...response.output] : []; + + for (const [key, text] of state.reasoningSummaryText.entries()) { + const [outputIndexText, summaryIndexText] = key.split(":"); + const outputIndex = Number.parseInt(outputIndexText ?? "", 10); + const summaryIndex = Number.parseInt(summaryIndexText ?? "", 10); + if (!Number.isFinite(outputIndex) || !Number.isFinite(summaryIndex)) continue; + const item = ensureOutputItemAtIndex(output, outputIndex); + if (!item) continue; + const summary = Array.isArray(item.summary) ? [...item.summary] : []; + while (summary.length <= summaryIndex) { + summary.push({}); + } + const current = summary[summaryIndex]; + const nextPart = isRecord(current) ? { ...current } : {}; + if (!getStringField(nextPart, "type")) { + nextPart.type = "summary_text"; + } + nextPart.text = text; + summary[summaryIndex] = nextPart; + item.summary = summary; + if (!getStringField(item, "type")) { + item.type = "reasoning"; + } + } + + if (output.length > 0) { + response.output = output; + } +} + +function finalizeParsedResponse(state: ParsedResponseState): MutableRecord | null { + const response = state.finalResponse ? { ...state.finalResponse } : null; + if (!response) return null; + + mergeOutputItemsIntoResponse(response, state); + applyAccumulatedOutputText(response, state); + applyReasoningSummaries(response, state); + + const output = Array.isArray(response.output) ? response.output : []; + if (typeof response.output_text !== "string") { + const outputText = collectMessageOutputText(output); + if (outputText.length > 0) { + response.output_text = outputText; + } + } + + const reasoningSummaryText = collectReasoningSummaryText(output); + if (reasoningSummaryText.length > 0) { + response.reasoning_summary_text = reasoningSummaryText; + } + + if (state.lastPhase && typeof response.phase !== "string") { + response.phase = state.lastPhase; + } + + if (state.phaseText.size > 0) { + const phaseText: MutableRecord = {}; + for (const [phase, text] of state.phaseText.entries()) { + phaseText[phase] = text; + if (phase === "commentary") response.commentary_text = text; + if (phase === "final_answer") response.final_answer_text = text; + } + response.phase_text = phaseText; + } + + return response; +} + function extractResponseId(response: unknown): string | null { if (!response || typeof response !== "object") return null; const candidate = (response as { id?: unknown }).id; @@ -32,28 +349,106 @@ function notifyResponseId( } } -type CapturedResponseEvent = - | { kind: "error" } - | { kind: "response"; response: unknown } - | null; - function maybeCaptureResponseEvent( + state: ParsedResponseState, data: SSEEventData, onResponseId?: (responseId: string) => void, -): CapturedResponseEvent { +): void { if (data.type === "error") { log.error("SSE error event received", { error: data }); - return { kind: "error" }; + return; } - if (data.type === "response.done" || data.type === "response.completed") { + if (isRecord(data.response)) { + state.finalResponse = { ...data.response }; notifyResponseId(onResponseId, data.response); - if (data.response !== undefined && data.response !== null) { - return { kind: "response", response: data.response }; + } + + if (data.type === "response.done" || data.type === "response.completed") { + return; + } + + const eventRecord = toMutableRecord(data); + if (!eventRecord) return; + const outputIndex = getNumberField(eventRecord, "output_index"); + + if (data.type === "response.output_item.added" || data.type === "response.output_item.done") { + upsertOutputItem(state, outputIndex, eventRecord.item); + return; + } + + if (data.type === "response.output_text.delta") { + appendOutputTextValue( + state, + outputIndex, + getNumberField(eventRecord, "content_index"), + getStringField(eventRecord, "delta"), + eventRecord.phase, + ); + return; + } + + if (data.type === "response.output_text.done") { + setOutputTextValue( + state, + outputIndex, + getNumberField(eventRecord, "content_index"), + getStringField(eventRecord, "text"), + eventRecord.phase, + ); + return; + } + + if (data.type === "response.content_part.added" || data.type === "response.content_part.done") { + const part = toMutableRecord(eventRecord.part); + if (!part || getStringField(part, "type") !== "output_text") { + capturePhase(state, part?.phase); + return; } + setOutputTextValue( + state, + outputIndex, + getNumberField(eventRecord, "content_index"), + getPartText(part), + part.phase, + ); + return; } - return null; + if (data.type === "response.reasoning_summary_text.delta") { + appendReasoningSummaryValue( + state, + outputIndex, + getNumberField(eventRecord, "summary_index"), + getStringField(eventRecord, "delta"), + ); + return; + } + + if (data.type === "response.reasoning_summary_text.done") { + setReasoningSummaryValue( + state, + outputIndex, + getNumberField(eventRecord, "summary_index"), + getStringField(eventRecord, "text"), + ); + return; + } + + if ( + data.type === "response.reasoning_summary_part.added" || + data.type === "response.reasoning_summary_part.done" + ) { + setReasoningSummaryValue( + state, + outputIndex, + getNumberField(eventRecord, "summary_index"), + getPartText(eventRecord.part), + ); + return; + } + + capturePhase(state, eventRecord.phase); } /** @@ -67,6 +462,7 @@ function parseSseStream( onResponseId?: (responseId: string) => void, ): unknown | null { const lines = sseText.split(/\r?\n/); + const state = createParsedResponseState(); for (const line of lines) { const trimmedLine = line.trim(); @@ -75,16 +471,14 @@ function parseSseStream( if (!payload || payload === '[DONE]') continue; try { const data = JSON.parse(payload) as SSEEventData; - const capturedEvent = maybeCaptureResponseEvent(data, onResponseId); - if (capturedEvent?.kind === "error") return null; - if (capturedEvent?.kind === "response") return capturedEvent.response; + maybeCaptureResponseEvent(state, data, onResponseId); } catch { // Skip malformed JSON } } } - return null; + return finalizeParsedResponse(state); } /** @@ -133,7 +527,9 @@ export async function convertSseToJson( if (!finalResponse) { log.warn("Could not find final response in SSE stream"); - logRequest("stream-error", { error: "No response.done event found" }); + logRequest("stream-error", { + error: "No terminal response event found in SSE stream", + }); // Return original stream if we can't parse return new Response(fullText, { @@ -173,10 +569,8 @@ function createResponseIdCapturingStream( ): ReadableStream { const decoder = new TextDecoder(); let bufferedText = ""; - let sawErrorEvent = false; const processBufferedLines = (flush = false): void => { - if (sawErrorEvent) return; const lines = bufferedText.split(/\r?\n/); if (!flush) { bufferedText = lines.pop() ?? ""; @@ -191,11 +585,7 @@ function createResponseIdCapturingStream( if (!payload || payload === "[DONE]") continue; try { const data = JSON.parse(payload) as SSEEventData; - const capturedEvent = maybeCaptureResponseEvent(data, onResponseId); - if (capturedEvent?.kind === "error") { - sawErrorEvent = true; - break; - } + maybeCaptureResponseEvent(createParsedResponseState(), data, onResponseId); } catch { // Ignore malformed SSE lines and keep forwarding the raw stream. } @@ -244,7 +634,7 @@ async function readWithTimeout( timeoutId = setTimeout(() => { reject( new Error( - `SSE stream stalled for ${timeoutMs}ms while waiting for response.done`, + `SSE stream stalled for ${timeoutMs}ms while waiting for a terminal response event`, ), ); }, timeoutMs); diff --git a/test/fetch-helpers.test.ts b/test/fetch-helpers.test.ts index 518a725c..f90108e7 100644 --- a/test/fetch-helpers.test.ts +++ b/test/fetch-helpers.test.ts @@ -743,6 +743,25 @@ describe('createEntitlementErrorResponse', () => { const text = await result.text(); expect(text).toBe('stream body'); }); + + it('captures response ids from streaming semantic SSE without rewriting the stream', async () => { + const onResponseId = vi.fn(); + const response = new Response( + [ + 'data: {"type":"response.created","response":{"id":"resp_stream_123"}}', + '', + 'data: {"type":"response.done","response":{"id":"resp_stream_123"}}', + '', + ].join('\n'), + { status: 200, headers: new Headers({ 'content-type': 'text/event-stream' }) }, + ); + + const result = await handleSuccessResponse(response, true, { onResponseId }); + const text = await result.text(); + + expect(text).toContain('"resp_stream_123"'); + expect(onResponseId).toHaveBeenCalledWith('resp_stream_123'); + }); }); describe('handleErrorResponse error normalization', () => { diff --git a/test/response-handler.test.ts b/test/response-handler.test.ts index 3e0fb0dd..14c4d9a7 100644 --- a/test/response-handler.test.ts +++ b/test/response-handler.test.ts @@ -66,6 +66,88 @@ data: {"type":"response.completed","response":{"id":"resp_456","output":"done"}} expect(body).toEqual({ id: 'resp_456', output: 'done' }); }); + it('synthesizes output_text and reasoning summaries from semantic SSE events', async () => { + const sseContent = [ + 'data: {"type":"response.created","response":{"id":"resp_semantic_123","object":"response"}}', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_123","type":"message","role":"assistant","phase":"final_answer"}}', + 'data: {"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"Hello ","phase":"final_answer"}', + 'data: {"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"world","phase":"final_answer"}', + 'data: {"type":"response.output_text.done","output_index":0,"content_index":0,"text":"Hello world","phase":"final_answer"}', + 'data: {"type":"response.output_item.added","output_index":1,"item":{"id":"rs_123","type":"reasoning"}}', + 'data: {"type":"response.reasoning_summary_text.delta","output_index":1,"summary_index":0,"delta":"Need more context."}', + 'data: {"type":"response.reasoning_summary_text.done","output_index":1,"summary_index":0,"text":"Need more context."}', + 'data: {"type":"response.completed","response":{"id":"resp_semantic_123","object":"response"}}', + '', + ].join('\n'); + const response = new Response(sseContent); + const headers = new Headers(); + + const result = await convertSseToJson(response, headers); + const body = await result.json() as { + id: string; + output?: Array<{ + type?: string; + role?: string; + phase?: string; + content?: Array<{ type?: string; text?: string }>; + summary?: Array<{ type?: string; text?: string }>; + }>; + output_text?: string; + reasoning_summary_text?: string; + phase?: string; + final_answer_text?: string; + phase_text?: Record; + }; + + expect(body.id).toBe('resp_semantic_123'); + expect(body.output_text).toBe('Hello world'); + expect(body.reasoning_summary_text).toBe('Need more context.'); + expect(body.phase).toBe('final_answer'); + expect(body.final_answer_text).toBe('Hello world'); + expect(body.phase_text).toEqual({ final_answer: 'Hello world' }); + expect(body.output?.[0]?.content?.[0]).toEqual({ + type: 'output_text', + text: 'Hello world', + }); + expect(body.output?.[1]?.summary?.[0]).toEqual({ + type: 'summary_text', + text: 'Need more context.', + }); + }); + + it('tracks commentary and final_answer phase text separately when phase labels are present', async () => { + const sseContent = [ + 'data: {"type":"response.created","response":{"id":"resp_phase_123","object":"response"}}', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_123","type":"message","role":"assistant","phase":"commentary"}}', + 'data: {"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"Thinking...","phase":"commentary"}', + 'data: {"type":"response.output_text.done","output_index":0,"content_index":0,"text":"Thinking...","phase":"commentary"}', + 'data: {"type":"response.output_item.done","output_index":0,"item":{"id":"msg_123","type":"message","role":"assistant","phase":"final_answer"}}', + 'data: {"type":"response.output_text.done","output_index":0,"content_index":1,"text":"Done.","phase":"final_answer"}', + 'data: {"type":"response.done","response":{"id":"resp_phase_123","object":"response"}}', + '', + ].join('\n'); + const response = new Response(sseContent); + const headers = new Headers(); + + const result = await convertSseToJson(response, headers); + const body = await result.json() as { + phase?: string; + commentary_text?: string; + final_answer_text?: string; + phase_text?: Record; + output_text?: string; + }; + + expect(body.phase).toBe('final_answer'); + expect(body.commentary_text).toBe('Thinking...'); + expect(body.final_answer_text).toBe('Done.'); + expect(body.phase_text).toEqual({ + commentary: 'Thinking...', + final_answer: 'Done.', + }); + expect(body.output_text).toBe('Thinking...Done.'); + }); + it('should return original text if no final response found', async () => { const sseContent = `data: {"type":"response.started"} data: {"type":"chunk","delta":"text"} From ad0443ba9d66b367a5b8a405d59c618aec8786ef Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:41:04 +0800 Subject: [PATCH 341/376] Fix semantic response id capture --- lib/request/response-handler.ts | 17 +++++++++++++---- test/fetch-helpers.test.ts | 1 + test/response-handler.test.ts | 32 +++++++++++++++++++++++++++++++- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/lib/request/response-handler.ts b/lib/request/response-handler.ts index a36f89ae..82219267 100644 --- a/lib/request/response-handler.ts +++ b/lib/request/response-handler.ts @@ -18,6 +18,7 @@ interface ParsedResponseState { outputText: Map; phaseText: Map; reasoningSummaryText: Map; + seenResponseIds: Set; } function createParsedResponseState(): ParsedResponseState { @@ -28,6 +29,7 @@ function createParsedResponseState(): ParsedResponseState { outputText: new Map(), phaseText: new Map(), reasoningSummaryText: new Map(), + seenResponseIds: new Set(), }; } @@ -54,7 +56,11 @@ function mergeRecord(base: MutableRecord | null, update: MutableRecord): Mutable if (!base) return { ...update }; const merged: MutableRecord = { ...base, ...update }; if ("content" in update || "content" in base) { - merged.content = cloneContentArray(update.content ?? base.content); + const updateContent = cloneContentArray(update.content); + merged.content = + updateContent.length > 0 || !("content" in base) + ? updateContent + : cloneContentArray(base.content); } return merged; } @@ -334,11 +340,13 @@ function extractResponseId(response: unknown): string | null { } function notifyResponseId( + state: ParsedResponseState, onResponseId: ((responseId: string) => void) | undefined, response: unknown, ): void { const responseId = extractResponseId(response); - if (!responseId || !onResponseId) return; + if (!responseId || !onResponseId || state.seenResponseIds.has(responseId)) return; + state.seenResponseIds.add(responseId); try { onResponseId(responseId); } catch (error) { @@ -361,7 +369,7 @@ function maybeCaptureResponseEvent( if (isRecord(data.response)) { state.finalResponse = { ...data.response }; - notifyResponseId(onResponseId, data.response); + notifyResponseId(state, onResponseId, data.response); } if (data.type === "response.done" || data.type === "response.completed") { @@ -569,6 +577,7 @@ function createResponseIdCapturingStream( ): ReadableStream { const decoder = new TextDecoder(); let bufferedText = ""; + const state = createParsedResponseState(); const processBufferedLines = (flush = false): void => { const lines = bufferedText.split(/\r?\n/); @@ -585,7 +594,7 @@ function createResponseIdCapturingStream( if (!payload || payload === "[DONE]") continue; try { const data = JSON.parse(payload) as SSEEventData; - maybeCaptureResponseEvent(createParsedResponseState(), data, onResponseId); + maybeCaptureResponseEvent(state, data, onResponseId); } catch { // Ignore malformed SSE lines and keep forwarding the raw stream. } diff --git a/test/fetch-helpers.test.ts b/test/fetch-helpers.test.ts index f90108e7..8efc73c1 100644 --- a/test/fetch-helpers.test.ts +++ b/test/fetch-helpers.test.ts @@ -761,6 +761,7 @@ describe('createEntitlementErrorResponse', () => { expect(text).toContain('"resp_stream_123"'); expect(onResponseId).toHaveBeenCalledWith('resp_stream_123'); + expect(onResponseId).toHaveBeenCalledTimes(1); }); }); diff --git a/test/response-handler.test.ts b/test/response-handler.test.ts index 14c4d9a7..d740e0f3 100644 --- a/test/response-handler.test.ts +++ b/test/response-handler.test.ts @@ -115,6 +115,31 @@ data: {"type":"response.completed","response":{"id":"resp_456","output":"done"}} }); }); + it('preserves richer terminal output when semantic items arrive with empty content arrays', async () => { + const sseContent = [ + 'data: {"type":"response.created","response":{"id":"resp_rich_123","object":"response"}}', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_123","type":"message","role":"assistant","content":[]}}', + 'data: {"type":"response.completed","response":{"id":"resp_rich_123","object":"response","output":[{"id":"msg_123","type":"message","role":"assistant","content":[{"type":"output_text","text":"Hello rich world"},{"type":"annotation","label":"kept"}]}]}}', + '', + ].join('\n'); + const response = new Response(sseContent); + const headers = new Headers(); + + const result = await convertSseToJson(response, headers); + const body = await result.json() as { + id: string; + output?: Array<{ + content?: Array<{ type?: string; text?: string; label?: string }>; + }>; + }; + + expect(body.id).toBe('resp_rich_123'); + expect(body.output?.[0]?.content).toEqual([ + { type: 'output_text', text: 'Hello rich world' }, + { type: 'annotation', label: 'kept' }, + ]); + }); + it('tracks commentary and final_answer phase text separately when phase labels are present', async () => { const sseContent = [ 'data: {"type":"response.created","response":{"id":"resp_phase_123","object":"response"}}', @@ -200,7 +225,11 @@ data: {"type":"response.done","response":{"id":"resp_789"}} it('should report the final response id while converting SSE to JSON', async () => { const onResponseId = vi.fn(); - const sseContent = `data: {"type":"response.done","response":{"id":"resp_123","output":"test"}}`; + const sseContent = [ + 'data: {"type":"response.created","response":{"id":"resp_123","object":"response"}}', + 'data: {"type":"response.done","response":{"id":"resp_123","output":"test"}}', + '', + ].join('\n'); const response = new Response(sseContent); const headers = new Headers(); @@ -209,6 +238,7 @@ data: {"type":"response.done","response":{"id":"resp_789"}} expect(body).toEqual({ id: 'resp_123', output: 'test' }); expect(onResponseId).toHaveBeenCalledWith('resp_123'); + expect(onResponseId).toHaveBeenCalledTimes(1); }); it('should return the raw SSE text when an error event arrives before response.done', async () => { From eb3d7a339d3e2eca5cd5bdb79eac41c47566a489 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:42:52 +0800 Subject: [PATCH 342/376] fix: tighten semantic SSE response handling --- lib/request/response-handler.ts | 67 +++++++++++++++++++++++++++------ test/response-handler.test.ts | 27 +++++++++++++ 2 files changed, 82 insertions(+), 12 deletions(-) diff --git a/lib/request/response-handler.ts b/lib/request/response-handler.ts index 82219267..3f5a485c 100644 --- a/lib/request/response-handler.ts +++ b/lib/request/response-handler.ts @@ -16,6 +16,7 @@ interface ParsedResponseState { lastPhase: string | null; outputItems: Map; outputText: Map; + phaseTextSegments: Map; phaseText: Map; reasoningSummaryText: Map; seenResponseIds: Set; @@ -27,6 +28,7 @@ function createParsedResponseState(): ParsedResponseState { lastPhase: null, outputItems: new Map(), outputText: new Map(), + phaseTextSegments: new Map(), phaseText: new Map(), reasoningSummaryText: new Map(), seenResponseIds: new Set(), @@ -70,6 +72,10 @@ function makeOutputTextKey(outputIndex: number | null, contentIndex: number | nu return `${outputIndex}:${contentIndex}`; } +function makePhaseTextSegmentKey(phase: string, outputTextKey: string): string { + return `${phase}\u0000${outputTextKey}`; +} + function makeSummaryKey(outputIndex: number | null, summaryIndex: number | null): string | null { if (outputIndex === null || summaryIndex === null) return null; return `${outputIndex}:${summaryIndex}`; @@ -85,15 +91,58 @@ function getPartText(part: unknown): string | null { function capturePhase( state: ParsedResponseState, phase: unknown, - text: string | null = null, +): void { + if (typeof phase !== "string" || phase.trim().length === 0) return; + state.lastPhase = phase.trim(); +} + +function syncPhaseText(state: ParsedResponseState, phase: string): void { + const prefix = `${phase}\u0000`; + const text = [...state.phaseTextSegments.entries()] + .filter(([key]) => key.startsWith(prefix)) + .map(([, value]) => value) + .join(""); + if (text.length === 0) { + state.phaseText.delete(phase); + return; + } + state.phaseText.set(phase, text); +} + +function setPhaseTextSegment( + state: ParsedResponseState, + phase: unknown, + outputTextKey: string, + text: string | null, ): void { if (typeof phase !== "string" || phase.trim().length === 0) return; const normalizedPhase = phase.trim(); state.lastPhase = normalizedPhase; - if (text && text.length > 0) { - const existing = state.phaseText.get(normalizedPhase) ?? ""; - state.phaseText.set(normalizedPhase, `${existing}${text}`); + const segmentKey = makePhaseTextSegmentKey(normalizedPhase, outputTextKey); + if (!text || text.length === 0) { + state.phaseTextSegments.delete(segmentKey); + syncPhaseText(state, normalizedPhase); + return; } + state.phaseTextSegments.set(segmentKey, text); + syncPhaseText(state, normalizedPhase); +} + +function appendPhaseTextSegment( + state: ParsedResponseState, + phase: unknown, + outputTextKey: string, + delta: string | null, +): void { + if (!delta || delta.length === 0 || typeof phase !== "string" || phase.trim().length === 0) { + return; + } + const normalizedPhase = phase.trim(); + state.lastPhase = normalizedPhase; + const segmentKey = makePhaseTextSegmentKey(normalizedPhase, outputTextKey); + const existing = state.phaseTextSegments.get(segmentKey) ?? ""; + state.phaseTextSegments.set(segmentKey, `${existing}${delta}`); + syncPhaseText(state, normalizedPhase); } function upsertOutputItem(state: ParsedResponseState, outputIndex: number | null, item: unknown): void { @@ -114,14 +163,8 @@ function setOutputTextValue( if (!text) return; const key = makeOutputTextKey(outputIndex, contentIndex); if (!key) return; - const existing = state.outputText.get(key) ?? ""; state.outputText.set(key, text); - const phaseDelta = existing.length > 0 && text.startsWith(existing) - ? text.slice(existing.length) - : existing === text - ? "" - : text; - capturePhase(state, phase, phaseDelta); + setPhaseTextSegment(state, phase, key, text); } function appendOutputTextValue( @@ -136,7 +179,7 @@ function appendOutputTextValue( if (!key) return; const existing = state.outputText.get(key) ?? ""; state.outputText.set(key, `${existing}${delta}`); - capturePhase(state, phase, delta); + appendPhaseTextSegment(state, phase, key, delta); } function setReasoningSummaryValue( diff --git a/test/response-handler.test.ts b/test/response-handler.test.ts index d740e0f3..a25386de 100644 --- a/test/response-handler.test.ts +++ b/test/response-handler.test.ts @@ -173,6 +173,32 @@ data: {"type":"response.completed","response":{"id":"resp_456","output":"done"}} expect(body.output_text).toBe('Thinking...Done.'); }); + it('replaces phase text when output_text.done corrects earlier deltas', async () => { + const sseContent = [ + 'data: {"type":"response.created","response":{"id":"resp_phase_fix","object":"response"}}', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_fix","type":"message","role":"assistant","phase":"final_answer"}}', + 'data: {"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"Hellp","phase":"final_answer"}', + 'data: {"type":"response.output_text.done","output_index":0,"content_index":0,"text":"Hello","phase":"final_answer"}', + 'data: {"type":"response.done","response":{"id":"resp_phase_fix","object":"response"}}', + '', + ].join('\n'); + const response = new Response(sseContent); + const headers = new Headers(); + + const result = await convertSseToJson(response, headers); + const body = await result.json() as { + output_text?: string; + final_answer_text?: string; + phase_text?: Record; + output?: Array<{ content?: Array<{ text?: string }> }>; + }; + + expect(body.output?.[0]?.content?.[0]?.text).toBe('Hello'); + expect(body.output_text).toBe('Hello'); + expect(body.final_answer_text).toBe('Hello'); + expect(body.phase_text).toEqual({ final_answer: 'Hello' }); + }); + it('should return original text if no final response found', async () => { const sseContent = `data: {"type":"response.started"} data: {"type":"chunk","delta":"text"} @@ -329,6 +355,7 @@ data: {"type":"response.done","response":{"id":"resp_789"}} const text = await captured.text(); expect(text).toBe(sseContent); + expect(onResponseId).toHaveBeenCalledTimes(1); expect(onResponseId).toHaveBeenCalledWith('resp_stream_123'); expect(captured.headers.get('content-type')).toBe('text/event-stream'); }); From 19edb998d41d719f453cf4ab5e6d33a09c8aec45 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:56:16 +0800 Subject: [PATCH 343/376] Abort response-id capture after SSE errors --- lib/request/response-handler.ts | 31 ++++++++++++++++---- test/response-handler.test.ts | 50 +++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/lib/request/response-handler.ts b/lib/request/response-handler.ts index 3f5a485c..77e1ff59 100644 --- a/lib/request/response-handler.ts +++ b/lib/request/response-handler.ts @@ -16,10 +16,12 @@ interface ParsedResponseState { lastPhase: string | null; outputItems: Map; outputText: Map; + outputTextPhases: Map; phaseTextSegments: Map; phaseText: Map; reasoningSummaryText: Map; seenResponseIds: Set; + encounteredError: boolean; } function createParsedResponseState(): ParsedResponseState { @@ -28,10 +30,12 @@ function createParsedResponseState(): ParsedResponseState { lastPhase: null, outputItems: new Map(), outputText: new Map(), + outputTextPhases: new Map(), phaseTextSegments: new Map(), phaseText: new Map(), reasoningSummaryText: new Map(), seenResponseIds: new Set(), + encounteredError: false, }; } @@ -115,8 +119,12 @@ function setPhaseTextSegment( outputTextKey: string, text: string | null, ): void { - if (typeof phase !== "string" || phase.trim().length === 0) return; - const normalizedPhase = phase.trim(); + const normalizedPhase = + typeof phase === "string" && phase.trim().length > 0 + ? phase.trim() + : state.outputTextPhases.get(outputTextKey) ?? null; + if (!normalizedPhase) return; + state.outputTextPhases.set(outputTextKey, normalizedPhase); state.lastPhase = normalizedPhase; const segmentKey = makePhaseTextSegmentKey(normalizedPhase, outputTextKey); if (!text || text.length === 0) { @@ -134,10 +142,15 @@ function appendPhaseTextSegment( outputTextKey: string, delta: string | null, ): void { - if (!delta || delta.length === 0 || typeof phase !== "string" || phase.trim().length === 0) { + if (!delta || delta.length === 0) { return; } - const normalizedPhase = phase.trim(); + const normalizedPhase = + typeof phase === "string" && phase.trim().length > 0 + ? phase.trim() + : state.outputTextPhases.get(outputTextKey) ?? null; + if (!normalizedPhase) return; + state.outputTextPhases.set(outputTextKey, normalizedPhase); state.lastPhase = normalizedPhase; const segmentKey = makePhaseTextSegmentKey(normalizedPhase, outputTextKey); const existing = state.phaseTextSegments.get(segmentKey) ?? ""; @@ -339,6 +352,7 @@ function applyReasoningSummaries(response: MutableRecord, state: ParsedResponseS function finalizeParsedResponse(state: ParsedResponseState): MutableRecord | null { const response = state.finalResponse ? { ...state.finalResponse } : null; if (!response) return null; + if (state.encounteredError) return null; mergeOutputItemsIntoResponse(response, state); applyAccumulatedOutputText(response, state); @@ -353,7 +367,10 @@ function finalizeParsedResponse(state: ParsedResponseState): MutableRecord | nul } const reasoningSummaryText = collectReasoningSummaryText(output); - if (reasoningSummaryText.length > 0) { + if ( + reasoningSummaryText.length > 0 && + typeof response.reasoning_summary_text !== "string" + ) { response.reasoning_summary_text = reasoningSummaryText; } @@ -407,6 +424,7 @@ function maybeCaptureResponseEvent( ): void { if (data.type === "error") { log.error("SSE error event received", { error: data }); + state.encounteredError = true; return; } @@ -523,6 +541,7 @@ function parseSseStream( try { const data = JSON.parse(payload) as SSEEventData; maybeCaptureResponseEvent(state, data, onResponseId); + if (state.encounteredError) return null; } catch { // Skip malformed JSON } @@ -623,6 +642,7 @@ function createResponseIdCapturingStream( const state = createParsedResponseState(); const processBufferedLines = (flush = false): void => { + if (state.encounteredError) return; const lines = bufferedText.split(/\r?\n/); if (!flush) { bufferedText = lines.pop() ?? ""; @@ -638,6 +658,7 @@ function createResponseIdCapturingStream( try { const data = JSON.parse(payload) as SSEEventData; maybeCaptureResponseEvent(state, data, onResponseId); + if (state.encounteredError) break; } catch { // Ignore malformed SSE lines and keep forwarding the raw stream. } diff --git a/test/response-handler.test.ts b/test/response-handler.test.ts index a25386de..8a3f2114 100644 --- a/test/response-handler.test.ts +++ b/test/response-handler.test.ts @@ -115,6 +115,28 @@ data: {"type":"response.completed","response":{"id":"resp_456","output":"done"}} }); }); + it('preserves canonical terminal reasoning_summary_text over synthesized semantic text', async () => { + const sseContent = [ + 'data: {"type":"response.created","response":{"id":"resp_semantic_canonical","object":"response"}}', + 'data: {"type":"response.output_item.added","output_index":1,"item":{"id":"rs_456","type":"reasoning"}}', + 'data: {"type":"response.reasoning_summary_text.delta","output_index":1,"summary_index":0,"delta":"Draft summary"}', + 'data: {"type":"response.reasoning_summary_text.done","output_index":1,"summary_index":0,"text":"Draft summary"}', + 'data: {"type":"response.completed","response":{"id":"resp_semantic_canonical","object":"response","reasoning_summary_text":"Canonical summary"}}', + '', + ].join('\n'); + const response = new Response(sseContent); + const headers = new Headers(); + + const result = await convertSseToJson(response, headers); + const body = await result.json() as { + reasoning_summary_text?: string; + output?: Array<{ summary?: Array<{ text?: string }> }>; + }; + + expect(body.reasoning_summary_text).toBe('Canonical summary'); + expect(body.output?.[0]?.summary?.[0]?.text).toBe('Draft summary'); + }); + it('preserves richer terminal output when semantic items arrive with empty content arrays', async () => { const sseContent = [ 'data: {"type":"response.created","response":{"id":"resp_rich_123","object":"response"}}', @@ -199,6 +221,32 @@ data: {"type":"response.completed","response":{"id":"resp_456","output":"done"}} expect(body.phase_text).toEqual({ final_answer: 'Hello' }); }); + it('replaces phase text when output_text.done omits phase after earlier deltas set it', async () => { + const sseContent = [ + 'data: {"type":"response.created","response":{"id":"resp_phase_fix_missing","object":"response"}}', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_fix_missing","type":"message","role":"assistant","phase":"final_answer"}}', + 'data: {"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"Hellp","phase":"final_answer"}', + 'data: {"type":"response.output_text.done","output_index":0,"content_index":0,"text":"Hello"}', + 'data: {"type":"response.done","response":{"id":"resp_phase_fix_missing","object":"response"}}', + '', + ].join('\n'); + const response = new Response(sseContent); + const headers = new Headers(); + + const result = await convertSseToJson(response, headers); + const body = await result.json() as { + output_text?: string; + final_answer_text?: string; + phase_text?: Record; + output?: Array<{ content?: Array<{ text?: string }> }>; + }; + + expect(body.output?.[0]?.content?.[0]?.text).toBe('Hello'); + expect(body.output_text).toBe('Hello'); + expect(body.final_answer_text).toBe('Hello'); + expect(body.phase_text).toEqual({ final_answer: 'Hello' }); + }); + it('should return original text if no final response found', async () => { const sseContent = `data: {"type":"response.started"} data: {"type":"chunk","delta":"text"} @@ -270,6 +318,8 @@ data: {"type":"response.done","response":{"id":"resp_789"}} it('should return the raw SSE text when an error event arrives before response.done', async () => { const onResponseId = vi.fn(); const sseContent = [ + 'data: {"type":"response.created","response":{"id":"resp_bad_123","object":"response"}}', + '', 'data: {"type":"error","message":"quota exceeded"}', '', 'data: {"type":"response.done","response":{"id":"resp_bad_123","output":"bad"}}', From b5eba0a3f6a2eadc5e7195fc5fdcf16e0c745e8a Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:01:59 +0800 Subject: [PATCH 344/376] Tighten semantic response parser fallbacks --- lib/request/response-handler.ts | 2 +- test/response-handler.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/request/response-handler.ts b/lib/request/response-handler.ts index 77e1ff59..115451ff 100644 --- a/lib/request/response-handler.ts +++ b/lib/request/response-handler.ts @@ -430,10 +430,10 @@ function maybeCaptureResponseEvent( if (isRecord(data.response)) { state.finalResponse = { ...data.response }; - notifyResponseId(state, onResponseId, data.response); } if (data.type === "response.done" || data.type === "response.completed") { + notifyResponseId(state, onResponseId, data.response); return; } diff --git a/test/response-handler.test.ts b/test/response-handler.test.ts index 8a3f2114..92b010d6 100644 --- a/test/response-handler.test.ts +++ b/test/response-handler.test.ts @@ -134,7 +134,7 @@ data: {"type":"response.completed","response":{"id":"resp_456","output":"done"}} }; expect(body.reasoning_summary_text).toBe('Canonical summary'); - expect(body.output?.[0]?.summary?.[0]?.text).toBe('Draft summary'); + expect(body.output?.[1]?.summary?.[0]?.text).toBe('Draft summary'); }); it('preserves richer terminal output when semantic items arrive with empty content arrays', async () => { From e0fec5a97de6364362efefa33e25f6d66b3a3fa6 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:11:03 +0800 Subject: [PATCH 345/376] Preserve whitespace-only response deltas --- lib/request/response-handler.ts | 9 +++++++-- test/response-handler.test.ts | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/lib/request/response-handler.ts b/lib/request/response-handler.ts index 115451ff..ba056106 100644 --- a/lib/request/response-handler.ts +++ b/lib/request/response-handler.ts @@ -53,6 +53,11 @@ function getStringField(record: MutableRecord, key: string): string | null { return typeof value === "string" && value.trim().length > 0 ? value : null; } +function getDeltaField(record: MutableRecord, key: string): string | null { + const value = record[key]; + return typeof value === "string" && value.length > 0 ? value : null; +} + function cloneContentArray(content: unknown): MutableRecord[] { if (!Array.isArray(content)) return []; return content.filter(isRecord).map((part) => ({ ...part })); @@ -451,7 +456,7 @@ function maybeCaptureResponseEvent( state, outputIndex, getNumberField(eventRecord, "content_index"), - getStringField(eventRecord, "delta"), + getDeltaField(eventRecord, "delta"), eventRecord.phase, ); return; @@ -489,7 +494,7 @@ function maybeCaptureResponseEvent( state, outputIndex, getNumberField(eventRecord, "summary_index"), - getStringField(eventRecord, "delta"), + getDeltaField(eventRecord, "delta"), ); return; } diff --git a/test/response-handler.test.ts b/test/response-handler.test.ts index 92b010d6..36981f6d 100644 --- a/test/response-handler.test.ts +++ b/test/response-handler.test.ts @@ -137,6 +137,41 @@ data: {"type":"response.completed","response":{"id":"resp_456","output":"done"}} expect(body.output?.[1]?.summary?.[0]?.text).toBe('Draft summary'); }); + it('preserves whitespace-only semantic deltas when no done events override them', async () => { + const sseContent = [ + 'data: {"type":"response.created","response":{"id":"resp_whitespace_delta","object":"response"}}', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_space","type":"message","role":"assistant","phase":"final_answer"}}', + 'data: {"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"Hello","phase":"final_answer"}', + 'data: {"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":" ","phase":"final_answer"}', + 'data: {"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"world","phase":"final_answer"}', + 'data: {"type":"response.output_item.added","output_index":1,"item":{"id":"rs_space","type":"reasoning"}}', + 'data: {"type":"response.reasoning_summary_text.delta","output_index":1,"summary_index":0,"delta":"Need"}', + 'data: {"type":"response.reasoning_summary_text.delta","output_index":1,"summary_index":0,"delta":" "}', + 'data: {"type":"response.reasoning_summary_text.delta","output_index":1,"summary_index":0,"delta":"context."}', + 'data: {"type":"response.done","response":{"id":"resp_whitespace_delta","object":"response"}}', + '', + ].join('\n'); + const response = new Response(sseContent); + const headers = new Headers(); + + const result = await convertSseToJson(response, headers); + const body = await result.json() as { + output_text?: string; + final_answer_text?: string; + reasoning_summary_text?: string; + output?: Array<{ + content?: Array<{ text?: string }>; + summary?: Array<{ text?: string }>; + }>; + }; + + expect(body.output?.[0]?.content?.[0]?.text).toBe('Hello world'); + expect(body.output_text).toBe('Hello world'); + expect(body.final_answer_text).toBe('Hello world'); + expect(body.output?.[1]?.summary?.[0]?.text).toBe('Need context.'); + expect(body.reasoning_summary_text).toBe('Need context.'); + }); + it('preserves richer terminal output when semantic items arrive with empty content arrays', async () => { const sseContent = [ 'data: {"type":"response.created","response":{"id":"resp_rich_123","object":"response"}}', From acc6b2b8478a889bea427d6f97e3e071d1130302 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:20:41 +0800 Subject: [PATCH 346/376] Require terminal response events for SSE JSON --- lib/request/response-handler.ts | 7 +++---- test/response-handler.test.ts | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/lib/request/response-handler.ts b/lib/request/response-handler.ts index ba056106..01089f8f 100644 --- a/lib/request/response-handler.ts +++ b/lib/request/response-handler.ts @@ -433,11 +433,10 @@ function maybeCaptureResponseEvent( return; } - if (isRecord(data.response)) { - state.finalResponse = { ...data.response }; - } - if (data.type === "response.done" || data.type === "response.completed") { + if (isRecord(data.response)) { + state.finalResponse = { ...data.response }; + } notifyResponseId(state, onResponseId, data.response); return; } diff --git a/test/response-handler.test.ts b/test/response-handler.test.ts index 36981f6d..7453a230 100644 --- a/test/response-handler.test.ts +++ b/test/response-handler.test.ts @@ -370,6 +370,24 @@ data: {"type":"response.done","response":{"id":"resp_789"}} expect(onResponseId).not.toHaveBeenCalled(); }); + it('should return the raw SSE text when a stream ends after response.created without a terminal event', async () => { + const onResponseId = vi.fn(); + const sseContent = [ + 'data: {"type":"response.created","response":{"id":"resp_partial_123","object":"response"}}', + '', + 'data: {"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"partial"}', + '', + ].join('\n'); + const response = new Response(sseContent); + const headers = new Headers(); + + const result = await convertSseToJson(response, headers, { onResponseId }); + const text = await result.text(); + + expect(text).toBe(sseContent); + expect(onResponseId).not.toHaveBeenCalled(); + }); + it('should throw error if SSE stream exceeds size limit', async () => { const largeContent = 'a'.repeat(20 * 1024 * 1024 + 1); const response = new Response(largeContent); From da2e5a43a5d9e32d5f1d04c141beeef90a084982 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:29:19 +0800 Subject: [PATCH 347/376] fix: harden semantic sse parsing --- lib/request/response-handler.ts | 43 +++++++++++++++++++++++--- test/response-handler.test.ts | 55 +++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/lib/request/response-handler.ts b/lib/request/response-handler.ts index 01089f8f..a413d03e 100644 --- a/lib/request/response-handler.ts +++ b/lib/request/response-handler.ts @@ -8,6 +8,7 @@ const log = createLogger("response-handler"); const MAX_SSE_SIZE = 10 * 1024 * 1024; // 10MB limit to prevent memory exhaustion const DEFAULT_STREAM_STALL_TIMEOUT_MS = 45_000; +const MAX_SYNTHESIZED_EVENT_INDEX = 255; type MutableRecord = Record; @@ -58,6 +59,15 @@ function getDeltaField(record: MutableRecord, key: string): string | null { return typeof value === "string" && value.length > 0 ? value : null; } +function isValidSynthesizedIndex(index: number | null): index is number { + return ( + index !== null && + Number.isInteger(index) && + index >= 0 && + index <= MAX_SYNTHESIZED_EVENT_INDEX + ); +} + function cloneContentArray(content: unknown): MutableRecord[] { if (!Array.isArray(content)) return []; return content.filter(isRecord).map((part) => ({ ...part })); @@ -77,7 +87,12 @@ function mergeRecord(base: MutableRecord | null, update: MutableRecord): Mutable } function makeOutputTextKey(outputIndex: number | null, contentIndex: number | null): string | null { - if (outputIndex === null || contentIndex === null) return null; + if ( + !isValidSynthesizedIndex(outputIndex) || + !isValidSynthesizedIndex(contentIndex) + ) { + return null; + } return `${outputIndex}:${contentIndex}`; } @@ -86,7 +101,12 @@ function makePhaseTextSegmentKey(phase: string, outputTextKey: string): string { } function makeSummaryKey(outputIndex: number | null, summaryIndex: number | null): string | null { - if (outputIndex === null || summaryIndex === null) return null; + if ( + !isValidSynthesizedIndex(outputIndex) || + !isValidSynthesizedIndex(summaryIndex) + ) { + return null; + } return `${outputIndex}:${summaryIndex}`; } @@ -164,7 +184,7 @@ function appendPhaseTextSegment( } function upsertOutputItem(state: ParsedResponseState, outputIndex: number | null, item: unknown): void { - if (outputIndex === null || !isRecord(item)) return; + if (!isValidSynthesizedIndex(outputIndex) || !isRecord(item)) return; const current = state.outputItems.get(outputIndex) ?? null; const merged = mergeRecord(current, item); state.outputItems.set(outputIndex, merged); @@ -226,6 +246,7 @@ function appendReasoningSummaryValue( } function ensureOutputItemAtIndex(output: unknown[], index: number): MutableRecord | null { + if (!isValidSynthesizedIndex(index)) return null; while (output.length <= index) { output.push({}); } @@ -237,6 +258,7 @@ function ensureOutputItemAtIndex(output: unknown[], index: number): MutableRecor } function ensureContentPartAtIndex(item: MutableRecord, index: number): MutableRecord | null { + if (!isValidSynthesizedIndex(index)) return null; const content = Array.isArray(item.content) ? [...item.content] : []; while (content.length <= index) { content.push({}); @@ -257,7 +279,12 @@ function applyAccumulatedOutputText(response: MutableRecord, state: ParsedRespon const [outputIndexText, contentIndexText] = key.split(":"); const outputIndex = Number.parseInt(outputIndexText ?? "", 10); const contentIndex = Number.parseInt(contentIndexText ?? "", 10); - if (!Number.isFinite(outputIndex) || !Number.isFinite(contentIndex)) continue; + if ( + !isValidSynthesizedIndex(outputIndex) || + !isValidSynthesizedIndex(contentIndex) + ) { + continue; + } const item = ensureOutputItemAtIndex(output, outputIndex); if (!item) continue; const part = ensureContentPartAtIndex(item, contentIndex); @@ -278,6 +305,7 @@ function mergeOutputItemsIntoResponse(response: MutableRecord, state: ParsedResp const output = Array.isArray(response.output) ? [...response.output] : []; for (const [outputIndex, item] of state.outputItems.entries()) { + if (!isValidSynthesizedIndex(outputIndex)) continue; while (output.length <= outputIndex) { output.push({}); } @@ -329,7 +357,12 @@ function applyReasoningSummaries(response: MutableRecord, state: ParsedResponseS const [outputIndexText, summaryIndexText] = key.split(":"); const outputIndex = Number.parseInt(outputIndexText ?? "", 10); const summaryIndex = Number.parseInt(summaryIndexText ?? "", 10); - if (!Number.isFinite(outputIndex) || !Number.isFinite(summaryIndex)) continue; + if ( + !isValidSynthesizedIndex(outputIndex) || + !isValidSynthesizedIndex(summaryIndex) + ) { + continue; + } const item = ensureOutputItemAtIndex(output, outputIndex); if (!item) continue; const summary = Array.isArray(item.summary) ? [...item.summary] : []; diff --git a/test/response-handler.test.ts b/test/response-handler.test.ts index 7453a230..9c180edb 100644 --- a/test/response-handler.test.ts +++ b/test/response-handler.test.ts @@ -137,6 +137,31 @@ data: {"type":"response.completed","response":{"id":"resp_456","output":"done"}} expect(body.output?.[1]?.summary?.[0]?.text).toBe('Draft summary'); }); + it('synthesizes reasoning summaries from part events', async () => { + const sseContent = [ + 'data: {"type":"response.created","response":{"id":"resp_summary_part","object":"response"}}', + 'data: {"type":"response.output_item.added","output_index":1,"item":{"id":"rs_part","type":"reasoning"}}', + 'data: {"type":"response.reasoning_summary_part.added","output_index":1,"summary_index":0,"part":{"text":"Draft summary"}}', + 'data: {"type":"response.reasoning_summary_part.done","output_index":1,"summary_index":0,"part":{"text":"Need more context."}}', + 'data: {"type":"response.done","response":{"id":"resp_summary_part","object":"response"}}', + '', + ].join('\n'); + const response = new Response(sseContent); + const headers = new Headers(); + + const result = await convertSseToJson(response, headers); + const body = await result.json() as { + reasoning_summary_text?: string; + output?: Array<{ summary?: Array<{ type?: string; text?: string }> }>; + }; + + expect(body.reasoning_summary_text).toBe('Need more context.'); + expect(body.output?.[1]?.summary?.[0]).toEqual({ + type: 'summary_text', + text: 'Need more context.', + }); + }); + it('preserves whitespace-only semantic deltas when no done events override them', async () => { const sseContent = [ 'data: {"type":"response.created","response":{"id":"resp_whitespace_delta","object":"response"}}', @@ -388,6 +413,36 @@ data: {"type":"response.done","response":{"id":"resp_789"}} expect(onResponseId).not.toHaveBeenCalled(); }); + it('ignores oversized semantic indices instead of building sparse output arrays', async () => { + const sseContent = [ + 'data: {"type":"response.created","response":{"id":"resp_guarded_indices","object":"response"}}', + 'data: {"type":"response.output_item.added","output_index":1000000,"item":{"id":"msg_big","type":"message","role":"assistant"}}', + 'data: {"type":"response.output_text.done","output_index":1000000,"content_index":1000000,"text":"ignored"}', + 'data: {"type":"response.reasoning_summary_part.done","output_index":1000000,"summary_index":1000000,"part":{"text":"ignored"}}', + 'data: {"type":"response.done","response":{"id":"resp_guarded_indices","object":"response"}}', + '', + ].join('\n'); + const response = new Response(sseContent); + const headers = new Headers(); + + const result = await convertSseToJson(response, headers); + const body = await result.json() as { + id: string; + object: string; + output?: unknown[]; + output_text?: string; + reasoning_summary_text?: string; + }; + + expect(body).toEqual({ + id: 'resp_guarded_indices', + object: 'response', + }); + expect(body.output).toBeUndefined(); + expect(body.output_text).toBeUndefined(); + expect(body.reasoning_summary_text).toBeUndefined(); + }); + it('should throw error if SSE stream exceeds size limit', async () => { const largeContent = 'a'.repeat(20 * 1024 * 1024 + 1); const response = new Response(largeContent); From 65f2fa063703cfeb54361670772e2449b5e95bf4 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:52:41 +0800 Subject: [PATCH 348/376] fix: preserve canonical semantic response text --- lib/request/response-handler.ts | 10 ++++++- test/response-handler.test.ts | 51 +++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/lib/request/response-handler.ts b/lib/request/response-handler.ts index a413d03e..87805135 100644 --- a/lib/request/response-handler.ts +++ b/lib/request/response-handler.ts @@ -198,9 +198,13 @@ function setOutputTextValue( text: string | null, phase: unknown = undefined, ): void { - if (!text) return; const key = makeOutputTextKey(outputIndex, contentIndex); if (!key) return; + if (!text) { + state.outputText.delete(key); + setPhaseTextSegment(state, phase, key, null); + return; + } state.outputText.set(key, text); setPhaseTextSegment(state, phase, key, text); } @@ -292,6 +296,10 @@ function applyAccumulatedOutputText(response: MutableRecord, state: ParsedRespon if (!getStringField(part, "type")) { part.type = "output_text"; } + if (typeof part.text === "string") { + setPhaseTextSegment(state, part.phase, key, part.text); + continue; + } part.text = text; } diff --git a/test/response-handler.test.ts b/test/response-handler.test.ts index 9c180edb..e2c4572c 100644 --- a/test/response-handler.test.ts +++ b/test/response-handler.test.ts @@ -222,6 +222,57 @@ data: {"type":"response.completed","response":{"id":"resp_456","output":"done"}} ]); }); + it('preserves canonical terminal content over accumulated deltas for the same slot', async () => { + const sseContent = [ + 'data: {"type":"response.created","response":{"id":"resp_canonical_slot","object":"response"}}', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_canonical","type":"message","role":"assistant","phase":"final_answer"}}', + 'data: {"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"Draft answer","phase":"final_answer"}', + 'data: {"type":"response.completed","response":{"id":"resp_canonical_slot","object":"response","output":[{"id":"msg_canonical","type":"message","role":"assistant","content":[{"type":"output_text","text":"Canonical answer"}]}]}}', + '', + ].join('\n'); + const response = new Response(sseContent); + const headers = new Headers(); + + const result = await convertSseToJson(response, headers); + const body = await result.json() as { + output_text?: string; + final_answer_text?: string; + phase_text?: Record; + output?: Array<{ content?: Array<{ text?: string }> }>; + }; + + expect(body.output?.[0]?.content?.[0]?.text).toBe('Canonical answer'); + expect(body.output_text).toBe('Canonical answer'); + expect(body.final_answer_text).toBe('Canonical answer'); + expect(body.phase_text).toEqual({ final_answer: 'Canonical answer' }); + }); + + it('clears stale output_text deltas when done events omit canonical text', async () => { + const sseContent = [ + 'data: {"type":"response.created","response":{"id":"resp_stale_delta","object":"response"}}', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_stale","type":"message","role":"assistant","phase":"final_answer"}}', + 'data: {"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"Hello ","phase":"final_answer"}', + 'data: {"type":"response.output_text.done","output_index":0,"content_index":0,"text":" ","phase":"final_answer"}', + 'data: {"type":"response.done","response":{"id":"resp_stale_delta","object":"response"}}', + '', + ].join('\n'); + const response = new Response(sseContent); + const headers = new Headers(); + + const result = await convertSseToJson(response, headers); + const body = await result.json() as { + output_text?: string; + final_answer_text?: string; + phase_text?: Record; + output?: Array<{ content?: Array<{ text?: string }> }>; + }; + + expect(body.output?.[0]?.content).toBeUndefined(); + expect(body.output_text).toBeUndefined(); + expect(body.final_answer_text).toBeUndefined(); + expect(body.phase_text).toBeUndefined(); + }); + it('tracks commentary and final_answer phase text separately when phase labels are present', async () => { const sseContent = [ 'data: {"type":"response.created","response":{"id":"resp_phase_123","object":"response"}}', From 729cf2d856b256500bbf823009a142d33e3a3244 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 20:26:20 +0800 Subject: [PATCH 349/376] fix: clear stale reasoning summary deltas --- lib/request/response-handler.ts | 5 ++++- test/response-handler.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/lib/request/response-handler.ts b/lib/request/response-handler.ts index 87805135..f9648ab7 100644 --- a/lib/request/response-handler.ts +++ b/lib/request/response-handler.ts @@ -230,9 +230,12 @@ function setReasoningSummaryValue( summaryIndex: number | null, text: string | null, ): void { - if (!text) return; const key = makeSummaryKey(outputIndex, summaryIndex); if (!key) return; + if (!text) { + state.reasoningSummaryText.delete(key); + return; + } state.reasoningSummaryText.set(key, text); } diff --git a/test/response-handler.test.ts b/test/response-handler.test.ts index e2c4572c..17f0ae65 100644 --- a/test/response-handler.test.ts +++ b/test/response-handler.test.ts @@ -273,6 +273,28 @@ data: {"type":"response.completed","response":{"id":"resp_456","output":"done"}} expect(body.phase_text).toBeUndefined(); }); + it('clears stale reasoning_summary_text deltas when done events omit canonical text', async () => { + const sseContent = [ + 'data: {"type":"response.created","response":{"id":"resp_stale_reasoning","object":"response"}}', + 'data: {"type":"response.output_item.added","output_index":1,"item":{"id":"rs_stale","type":"reasoning"}}', + 'data: {"type":"response.reasoning_summary_text.delta","output_index":1,"summary_index":0,"delta":"Need more context"}', + 'data: {"type":"response.reasoning_summary_text.done","output_index":1,"summary_index":0,"text":" "}', + 'data: {"type":"response.done","response":{"id":"resp_stale_reasoning","object":"response"}}', + '', + ].join('\n'); + const response = new Response(sseContent); + const headers = new Headers(); + + const result = await convertSseToJson(response, headers); + const body = await result.json() as { + reasoning_summary_text?: string; + output?: Array<{ summary?: Array<{ text?: string }> }>; + }; + + expect(body.output?.[1]?.summary).toBeUndefined(); + expect(body.reasoning_summary_text).toBeUndefined(); + }); + it('tracks commentary and final_answer phase text separately when phase labels are present', async () => { const sseContent = [ 'data: {"type":"response.created","response":{"id":"resp_phase_123","object":"response"}}', From 5acb8ad643ea62e84ac41e9e732729c22058cf57 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 20:46:22 +0800 Subject: [PATCH 350/376] fix: preserve canonical response summaries --- lib/request/response-handler.ts | 3 ++ test/response-handler.test.ts | 54 +++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/lib/request/response-handler.ts b/lib/request/response-handler.ts index f9648ab7..3691ad5f 100644 --- a/lib/request/response-handler.ts +++ b/lib/request/response-handler.ts @@ -385,6 +385,9 @@ function applyReasoningSummaries(response: MutableRecord, state: ParsedResponseS if (!getStringField(nextPart, "type")) { nextPart.type = "summary_text"; } + if (typeof nextPart.text === "string") { + continue; + } nextPart.text = text; summary[summaryIndex] = nextPart; item.summary = summary; diff --git a/test/response-handler.test.ts b/test/response-handler.test.ts index 17f0ae65..92837943 100644 --- a/test/response-handler.test.ts +++ b/test/response-handler.test.ts @@ -137,6 +137,31 @@ data: {"type":"response.completed","response":{"id":"resp_456","output":"done"}} expect(body.output?.[1]?.summary?.[0]?.text).toBe('Draft summary'); }); + it('preserves canonical terminal reasoning summary parts over synthesized semantic text', async () => { + const sseContent = [ + 'data: {"type":"response.created","response":{"id":"resp_semantic_part_canonical","object":"response"}}', + 'data: {"type":"response.output_item.added","output_index":1,"item":{"id":"rs_789","type":"reasoning"}}', + 'data: {"type":"response.reasoning_summary_part.added","output_index":1,"summary_index":0,"part":{"text":"Draft summary"}}', + 'data: {"type":"response.reasoning_summary_part.done","output_index":1,"summary_index":0,"part":{"text":"Draft summary"}}', + 'data: {"type":"response.completed","response":{"id":"resp_semantic_part_canonical","object":"response","output":[{},{"type":"reasoning","summary":[{"type":"summary_text","text":"Canonical summary part"}]}]}}', + '', + ].join('\n'); + const response = new Response(sseContent); + const headers = new Headers(); + + const result = await convertSseToJson(response, headers); + const body = await result.json() as { + reasoning_summary_text?: string; + output?: Array<{ summary?: Array<{ type?: string; text?: string }> }>; + }; + + expect(body.reasoning_summary_text).toBe('Canonical summary part'); + expect(body.output?.[1]?.summary?.[0]).toEqual({ + type: 'summary_text', + text: 'Canonical summary part', + }); + }); + it('synthesizes reasoning summaries from part events', async () => { const sseContent = [ 'data: {"type":"response.created","response":{"id":"resp_summary_part","object":"response"}}', @@ -162,6 +187,35 @@ data: {"type":"response.completed","response":{"id":"resp_456","output":"done"}} }); }); + it('synthesizes output text from content_part events', async () => { + const sseContent = [ + 'data: {"type":"response.created","response":{"id":"resp_content_part","object":"response"}}', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_part","type":"message","role":"assistant","phase":"final_answer"}}', + 'data: {"type":"response.content_part.added","output_index":0,"content_index":0,"part":{"type":"output_text","text":"Hello ","phase":"final_answer"}}', + 'data: {"type":"response.content_part.done","output_index":0,"content_index":0,"part":{"type":"output_text","text":"Hello world","phase":"final_answer"}}', + 'data: {"type":"response.done","response":{"id":"resp_content_part","object":"response"}}', + '', + ].join('\n'); + const response = new Response(sseContent); + const headers = new Headers(); + + const result = await convertSseToJson(response, headers); + const body = await result.json() as { + output_text?: string; + final_answer_text?: string; + phase_text?: Record; + output?: Array<{ content?: Array<{ type?: string; text?: string }> }>; + }; + + expect(body.output?.[0]?.content?.[0]).toEqual({ + type: 'output_text', + text: 'Hello world', + }); + expect(body.output_text).toBe('Hello world'); + expect(body.final_answer_text).toBe('Hello world'); + expect(body.phase_text).toEqual({ final_answer: 'Hello world' }); + }); + it('preserves whitespace-only semantic deltas when no done events override them', async () => { const sseContent = [ 'data: {"type":"response.created","response":{"id":"resp_whitespace_delta","object":"response"}}', From 8d07f803e1b31f5ec10c26d3bcd9d73ea582e327 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 20:49:25 +0800 Subject: [PATCH 351/376] preserve canonical reasoning summary parts --- lib/request/response-handler.ts | 55 ++++++++++++++--- test/response-handler.test.ts | 103 ++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 8 deletions(-) diff --git a/lib/request/response-handler.ts b/lib/request/response-handler.ts index 3691ad5f..e5a0f628 100644 --- a/lib/request/response-handler.ts +++ b/lib/request/response-handler.ts @@ -19,6 +19,7 @@ interface ParsedResponseState { outputText: Map; outputTextPhases: Map; phaseTextSegments: Map; + phaseSegmentOrder: Map; phaseText: Map; reasoningSummaryText: Map; seenResponseIds: Set; @@ -33,6 +34,7 @@ function createParsedResponseState(): ParsedResponseState { outputText: new Map(), outputTextPhases: new Map(), phaseTextSegments: new Map(), + phaseSegmentOrder: new Map(), phaseText: new Map(), reasoningSummaryText: new Map(), seenResponseIds: new Set(), @@ -125,11 +127,40 @@ function capturePhase( state.lastPhase = phase.trim(); } -function syncPhaseText(state: ParsedResponseState, phase: string): void { - const prefix = `${phase}\u0000`; - const text = [...state.phaseTextSegments.entries()] - .filter(([key]) => key.startsWith(prefix)) - .map(([, value]) => value) +function rememberPhaseSegmentOrder( + state: ParsedResponseState, + phase: string, + segmentKey: string, +): string[] { + const existingOrder = state.phaseSegmentOrder.get(phase); + if (existingOrder?.includes(segmentKey)) { + return existingOrder; + } + const nextOrder = [...(existingOrder ?? []), segmentKey]; + state.phaseSegmentOrder.set(phase, nextOrder); + return nextOrder; +} + +function removePhaseSegmentOrder( + state: ParsedResponseState, + phase: string, + segmentKey: string, +): void { + const existingOrder = state.phaseSegmentOrder.get(phase); + if (!existingOrder) return; + const nextOrder = existingOrder.filter((key) => key !== segmentKey); + if (nextOrder.length === 0) { + state.phaseSegmentOrder.delete(phase); + return; + } + state.phaseSegmentOrder.set(phase, nextOrder); +} + +function rebuildPhaseText(state: ParsedResponseState, phase: string): void { + const orderedKeys = state.phaseSegmentOrder.get(phase) ?? []; + const text = orderedKeys + .map((key) => state.phaseTextSegments.get(key) ?? "") + .filter((value) => value.length > 0) .join(""); if (text.length === 0) { state.phaseText.delete(phase); @@ -154,11 +185,13 @@ function setPhaseTextSegment( const segmentKey = makePhaseTextSegmentKey(normalizedPhase, outputTextKey); if (!text || text.length === 0) { state.phaseTextSegments.delete(segmentKey); - syncPhaseText(state, normalizedPhase); + removePhaseSegmentOrder(state, normalizedPhase, segmentKey); + rebuildPhaseText(state, normalizedPhase); return; } + rememberPhaseSegmentOrder(state, normalizedPhase, segmentKey); state.phaseTextSegments.set(segmentKey, text); - syncPhaseText(state, normalizedPhase); + rebuildPhaseText(state, normalizedPhase); } function appendPhaseTextSegment( @@ -178,9 +211,15 @@ function appendPhaseTextSegment( state.outputTextPhases.set(outputTextKey, normalizedPhase); state.lastPhase = normalizedPhase; const segmentKey = makePhaseTextSegmentKey(normalizedPhase, outputTextKey); + const phaseOrder = rememberPhaseSegmentOrder(state, normalizedPhase, segmentKey); const existing = state.phaseTextSegments.get(segmentKey) ?? ""; state.phaseTextSegments.set(segmentKey, `${existing}${delta}`); - syncPhaseText(state, normalizedPhase); + if (phaseOrder[phaseOrder.length - 1] === segmentKey) { + const existingPhaseText = state.phaseText.get(normalizedPhase) ?? ""; + state.phaseText.set(normalizedPhase, `${existingPhaseText}${delta}`); + return; + } + rebuildPhaseText(state, normalizedPhase); } function upsertOutputItem(state: ParsedResponseState, outputIndex: number | null, item: unknown): void { diff --git a/test/response-handler.test.ts b/test/response-handler.test.ts index 92837943..60c41bdf 100644 --- a/test/response-handler.test.ts +++ b/test/response-handler.test.ts @@ -187,6 +187,28 @@ data: {"type":"response.completed","response":{"id":"resp_456","output":"done"}} }); }); + it('preserves canonical terminal reasoning summary text over synthesized summary deltas', async () => { + const sseContent = [ + 'data: {"type":"response.created","response":{"id":"resp_semantic_nested","object":"response"}}', + 'data: {"type":"response.output_item.added","output_index":1,"item":{"id":"rs_nested","type":"reasoning"}}', + 'data: {"type":"response.reasoning_summary_text.delta","output_index":1,"summary_index":0,"delta":"Draft nested summary"}', + 'data: {"type":"response.reasoning_summary_text.done","output_index":1,"summary_index":0,"text":"Draft nested summary"}', + 'data: {"type":"response.completed","response":{"id":"resp_semantic_nested","object":"response","output":[{},{"id":"rs_nested","type":"reasoning","summary":[{"type":"summary_text","text":"Canonical nested summary"}]}]}}', + '', + ].join('\n'); + const response = new Response(sseContent); + const headers = new Headers(); + + const result = await convertSseToJson(response, headers); + const body = await result.json() as { + reasoning_summary_text?: string; + output?: Array<{ summary?: Array<{ text?: string }> }>; + }; + + expect(body.output?.[1]?.summary?.[0]?.text).toBe('Canonical nested summary'); + expect(body.reasoning_summary_text).toBe('Canonical nested summary'); + }); + it('synthesizes output text from content_part events', async () => { const sseContent = [ 'data: {"type":"response.created","response":{"id":"resp_content_part","object":"response"}}', @@ -434,6 +456,87 @@ data: {"type":"response.completed","response":{"id":"resp_456","output":"done"}} expect(body.phase_text).toEqual({ final_answer: 'Hello' }); }); + it('handles response.content_part.added for output_text parts', async () => { + const sseContent = [ + 'data: {"type":"response.created","response":{"id":"resp_content_part_added","object":"response"}}', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_part_added","type":"message","role":"assistant","phase":"final_answer"}}', + 'data: {"type":"response.content_part.added","output_index":0,"content_index":0,"part":{"type":"output_text","text":"Hello from added part","phase":"final_answer"}}', + 'data: {"type":"response.done","response":{"id":"resp_content_part_added","object":"response"}}', + '', + ].join('\n'); + const response = new Response(sseContent); + const headers = new Headers(); + + const result = await convertSseToJson(response, headers); + const body = await result.json() as { + output_text?: string; + final_answer_text?: string; + phase_text?: Record; + output?: Array<{ content?: Array<{ type?: string; text?: string }> }>; + }; + + expect(body.output?.[0]?.content?.[0]).toEqual({ + type: 'output_text', + text: 'Hello from added part', + }); + expect(body.output_text).toBe('Hello from added part'); + expect(body.final_answer_text).toBe('Hello from added part'); + expect(body.phase_text).toEqual({ final_answer: 'Hello from added part' }); + }); + + it('handles response.content_part.done for output_text parts', async () => { + const sseContent = [ + 'data: {"type":"response.created","response":{"id":"resp_content_part_done","object":"response"}}', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_part_done","type":"message","role":"assistant","phase":"final_answer"}}', + 'data: {"type":"response.content_part.done","output_index":0,"content_index":0,"part":{"type":"output_text","text":"Hello from done part","phase":"final_answer"}}', + 'data: {"type":"response.done","response":{"id":"resp_content_part_done","object":"response"}}', + '', + ].join('\n'); + const response = new Response(sseContent); + const headers = new Headers(); + + const result = await convertSseToJson(response, headers); + const body = await result.json() as { + output_text?: string; + final_answer_text?: string; + phase_text?: Record; + output?: Array<{ content?: Array<{ type?: string; text?: string }> }>; + }; + + expect(body.output?.[0]?.content?.[0]).toEqual({ + type: 'output_text', + text: 'Hello from done part', + }); + expect(body.output_text).toBe('Hello from done part'); + expect(body.final_answer_text).toBe('Hello from done part'); + expect(body.phase_text).toEqual({ final_answer: 'Hello from done part' }); + }); + + it('captures phase from non-output_text content parts without mutating output text', async () => { + const sseContent = [ + 'data: {"type":"response.created","response":{"id":"resp_content_part_annotation","object":"response"}}', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_part_annotation","type":"message","role":"assistant"}}', + 'data: {"type":"response.content_part.added","output_index":0,"content_index":0,"part":{"type":"annotation","text":"ignored","phase":"commentary"}}', + 'data: {"type":"response.done","response":{"id":"resp_content_part_annotation","object":"response"}}', + '', + ].join('\n'); + const response = new Response(sseContent); + const headers = new Headers(); + + const result = await convertSseToJson(response, headers); + const body = await result.json() as { + phase?: string; + output_text?: string; + phase_text?: Record; + output?: Array<{ content?: Array<{ type?: string; text?: string }> }>; + }; + + expect(body.phase).toBe('commentary'); + expect(body.output?.[0]?.content).toBeUndefined(); + expect(body.output_text).toBeUndefined(); + expect(body.phase_text).toBeUndefined(); + }); + it('should return original text if no final response found', async () => { const sseContent = `data: {"type":"response.started"} data: {"type":"chunk","delta":"text"} From 26c00d8b12a8c5cc593a4bebc3fa5ee6c3ed672d Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 21:00:55 +0800 Subject: [PATCH 352/376] test: cover missing semantic output indices --- lib/request/response-handler.ts | 6 ++++++ test/response-handler.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/lib/request/response-handler.ts b/lib/request/response-handler.ts index e5a0f628..ee5a5bec 100644 --- a/lib/request/response-handler.ts +++ b/lib/request/response-handler.ts @@ -51,6 +51,12 @@ function getNumberField(record: MutableRecord, key: string): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } +/** + * Read a trimmed, non-empty string field for identifier-like values. + * + * For textual payloads where whitespace is meaningful, use a field-specific + * accessor such as `getDeltaField` instead of reusing this helper. + */ function getStringField(record: MutableRecord, key: string): string | null { const value = record[key]; return typeof value === "string" && value.trim().length > 0 ? value : null; diff --git a/test/response-handler.test.ts b/test/response-handler.test.ts index 60c41bdf..f372f7c3 100644 --- a/test/response-handler.test.ts +++ b/test/response-handler.test.ts @@ -673,6 +673,29 @@ data: {"type":"response.done","response":{"id":"resp_789"}} expect(body.reasoning_summary_text).toBeUndefined(); }); + it('ignores delta events with missing output_index', async () => { + const sseContent = [ + 'data: {"type":"response.created","response":{"id":"resp_no_index","object":"response"}}', + 'data: {"type":"response.output_text.delta","content_index":0,"delta":"orphan"}', + 'data: {"type":"response.reasoning_summary_text.delta","summary_index":0,"delta":"orphan"}', + 'data: {"type":"response.done","response":{"id":"resp_no_index","object":"response"}}', + '', + ].join('\n'); + const response = new Response(sseContent); + const headers = new Headers(); + + const result = await convertSseToJson(response, headers); + const body = await result.json() as { + id: string; + output_text?: string; + reasoning_summary_text?: string; + }; + + expect(body.id).toBe('resp_no_index'); + expect(body.output_text).toBeUndefined(); + expect(body.reasoning_summary_text).toBeUndefined(); + }); + it('should throw error if SSE stream exceeds size limit', async () => { const largeContent = 'a'.repeat(20 * 1024 * 1024 + 1); const response = new Response(largeContent); From 5a7184ccf6ef92cb2a2df714352a357bb519301e Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 16:28:19 +0800 Subject: [PATCH 353/376] add response compaction fallback for fast sessions --- index.ts | 34 +++++++ lib/request/fetch-helpers.ts | 27 ++++- lib/request/helpers/model-map.ts | 20 +++- lib/request/request-transformer.ts | 64 ++++++++++-- lib/request/response-compaction.ts | 158 +++++++++++++++++++++++++++++ test/codex-manager-cli.test.ts | 10 +- test/index.test.ts | 63 +++++++++++- test/model-map.test.ts | 8 ++ test/request-transformer.test.ts | 28 ++++- test/response-compaction.test.ts | 115 +++++++++++++++++++++ 10 files changed, 502 insertions(+), 25 deletions(-) create mode 100644 lib/request/response-compaction.ts create mode 100644 test/response-compaction.test.ts diff --git a/index.ts b/index.ts index 13c03abb..7fede6dd 100644 --- a/index.ts +++ b/index.ts @@ -154,6 +154,7 @@ import { isWorkspaceDisabledError, } from "./lib/request/fetch-helpers.js"; import { applyFastSessionDefaults } from "./lib/request/request-transformer.js"; +import { applyResponseCompaction } from "./lib/request/response-compaction.js"; import { getRateLimitBackoff, RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS, @@ -1351,10 +1352,13 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { fastSession: fastSessionEnabled, fastSessionStrategy, fastSessionMaxInputItems, + deferFastSessionInputTrimming: fastSessionEnabled, }, ); let requestInit = transformation?.updatedInit ?? baseInit; let transformedBody: RequestBody | undefined = transformation?.body; + const deferredFastSessionInputTrim = + transformation?.deferredFastSessionInputTrim; const promptCacheKey = transformedBody?.prompt_cache_key; let model = transformedBody?.model; let modelFamily = model ? getModelFamily(model) : "gpt-5.1"; @@ -1672,6 +1676,36 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { promptCacheKey: effectivePromptCacheKey, }, ); + if (transformedBody && deferredFastSessionInputTrim) { + const compactionResult = await applyResponseCompaction({ + body: transformedBody, + requestUrl: url, + headers, + trim: deferredFastSessionInputTrim, + fetchImpl: async (requestUrl, requestInit) => { + const normalizedCompactionUrl = + typeof requestUrl === "string" + ? requestUrl + : String(requestUrl); + return fetch( + normalizedCompactionUrl, + applyProxyCompatibleInit( + normalizedCompactionUrl, + requestInit, + ), + ); + }, + signal: abortSignal, + timeoutMs: Math.min(fetchTimeoutMs, 4_000), + }); + if (compactionResult.mode !== "unchanged") { + transformedBody = compactionResult.body; + requestInit = { + ...(requestInit ?? {}), + body: JSON.stringify(transformedBody), + }; + } + } const quotaScheduleKey = `${entitlementAccountKey}:${model ?? modelFamily}`; const capabilityModelKey = model ?? modelFamily; const quotaDeferral = preemptiveQuotaScheduler.getDeferral(quotaScheduleKey); diff --git a/lib/request/fetch-helpers.ts b/lib/request/fetch-helpers.ts index 37043418..348ea4f8 100644 --- a/lib/request/fetch-helpers.ts +++ b/lib/request/fetch-helpers.ts @@ -8,7 +8,12 @@ import { ProxyAgent } from "undici"; import { queuedRefresh } from "../refresh-queue.js"; import { logRequest, logError, logWarn } from "../logger.js"; import { getCodexInstructions, getModelFamily } from "../prompts/codex.js"; -import { transformRequestBody, normalizeModel } from "./request-transformer.js"; +import { + transformRequestBody, + normalizeModel, + resolveFastSessionInputTrimPlan, + type FastSessionInputTrimPlan, +} from "./request-transformer.js"; import { attachResponseIdCapture, convertSseToJson, @@ -99,6 +104,12 @@ export interface ResolveUnsupportedCodexFallbackOptions { customChain?: Record; } +export interface TransformRequestForCodexResult { + body: RequestBody; + updatedInit: RequestInit; + deferredFastSessionInputTrim?: FastSessionInputTrimPlan["trim"]; +} + function canonicalizeModelName(model: string | undefined): string | undefined { if (!model) return undefined; const trimmed = model.trim().toLowerCase(); @@ -651,8 +662,9 @@ export async function transformRequestForCodex( fastSession?: boolean; fastSessionStrategy?: "hybrid" | "always"; fastSessionMaxInputItems?: number; + deferFastSessionInputTrimming?: boolean; }, -): Promise<{ body: RequestBody; updatedInit: RequestInit } | undefined> { +): Promise { const hasParsedBody = parsedBody !== undefined && parsedBody !== null && @@ -670,6 +682,12 @@ export async function transformRequestForCodex( body = JSON.parse(init.body) as RequestBody; } const originalModel = body.model; + const fastSessionInputTrimPlan = resolveFastSessionInputTrimPlan( + body, + options?.fastSession ?? false, + options?.fastSessionStrategy ?? "hybrid", + options?.fastSessionMaxInputItems ?? 30, + ); // Normalize model first to determine which instructions to fetch // This ensures we get the correct model-specific prompt @@ -700,6 +718,7 @@ export async function transformRequestForCodex( options?.fastSession ?? false, options?.fastSessionStrategy ?? "hybrid", options?.fastSessionMaxInputItems ?? 30, + options?.deferFastSessionInputTrimming ?? false, ); // Log transformed request @@ -720,6 +739,10 @@ export async function transformRequestForCodex( return { body: transformedBody, updatedInit: { ...(init ?? {}), body: JSON.stringify(transformedBody) }, + deferredFastSessionInputTrim: + options?.deferFastSessionInputTrimming === true + ? fastSessionInputTrimPlan.trim + : undefined, }; } catch (e) { logError(`${ERROR_MESSAGES.REQUEST_PARSE_ERROR}`, e); diff --git a/lib/request/helpers/model-map.ts b/lib/request/helpers/model-map.ts index 20a6832d..b623c845 100644 --- a/lib/request/helpers/model-map.ts +++ b/lib/request/helpers/model-map.ts @@ -25,6 +25,7 @@ export type PromptModelFamily = export interface ModelCapabilities { toolSearch: boolean; computerUse: boolean; + compaction: boolean; } export interface ModelProfile { @@ -48,14 +49,27 @@ const TOOL_CAPABILITIES = { full: { toolSearch: true, computerUse: true, + compaction: true, }, computerOnly: { toolSearch: false, computerUse: true, + compaction: false, + }, + computerAndCompact: { + toolSearch: false, + computerUse: true, + compaction: true, + }, + compactOnly: { + toolSearch: false, + computerUse: false, + compaction: true, }, basic: { toolSearch: false, computerUse: false, + compaction: false, }, } as const satisfies Record; @@ -103,7 +117,7 @@ export const MODEL_PROFILES: Record = { promptFamily: "gpt-5.2", defaultReasoningEffort: "high", supportedReasoningEfforts: ["medium", "high", "xhigh"], - capabilities: TOOL_CAPABILITIES.computerOnly, + capabilities: TOOL_CAPABILITIES.computerAndCompact, }, "gpt-5.2-pro": { normalizedModel: "gpt-5.2-pro", @@ -145,14 +159,14 @@ export const MODEL_PROFILES: Record = { promptFamily: "gpt-5.2", defaultReasoningEffort: "medium", supportedReasoningEfforts: ["medium"], - capabilities: TOOL_CAPABILITIES.basic, + capabilities: TOOL_CAPABILITIES.full, }, "gpt-5-nano": { normalizedModel: "gpt-5-nano", promptFamily: "gpt-5.2", defaultReasoningEffort: "medium", supportedReasoningEfforts: ["medium"], - capabilities: TOOL_CAPABILITIES.basic, + capabilities: TOOL_CAPABILITIES.compactOnly, }, } as const; diff --git a/lib/request/request-transformer.ts b/lib/request/request-transformer.ts index 6c002476..3f6a3353 100644 --- a/lib/request/request-transformer.ts +++ b/lib/request/request-transformer.ts @@ -33,6 +33,7 @@ export interface TransformRequestBodyParams { fastSession?: boolean; fastSessionStrategy?: FastSessionStrategy; fastSessionMaxInputItems?: number; + deferFastSessionInputTrimming?: boolean; } const PLAN_MODE_ONLY_TOOLS = new Set(["request_user_input"]); @@ -482,6 +483,15 @@ export function trimInputForFastSession( return trimmed.slice(trimmed.length - safeMax); } +export interface FastSessionInputTrimPlan { + shouldApply: boolean; + isTrivialTurn: boolean; + trim?: { + maxItems: number; + preferLatestUserOnly: boolean; + }; +} + function isTrivialLatestPrompt(text: string): boolean { const normalized = text.trim(); if (!normalized) return false; @@ -540,6 +550,33 @@ function isComplexFastSessionRequest( return false; } +export function resolveFastSessionInputTrimPlan( + body: RequestBody, + fastSession: boolean, + fastSessionStrategy: FastSessionStrategy, + fastSessionMaxInputItems: number, +): FastSessionInputTrimPlan { + const shouldApplyFastSessionTuning = + fastSession && + (fastSessionStrategy === "always" || + !isComplexFastSessionRequest(body, fastSessionMaxInputItems)); + const latestUserText = getLatestUserText(body.input); + const isTrivialTurn = isTrivialLatestPrompt(latestUserText ?? ""); + const shouldPreferLatestUserOnly = + shouldApplyFastSessionTuning && isTrivialTurn; + + return { + shouldApply: shouldApplyFastSessionTuning, + isTrivialTurn, + trim: shouldApplyFastSessionTuning + ? { + maxItems: fastSessionMaxInputItems, + preferLatestUserOnly: shouldPreferLatestUserOnly, + } + : undefined, + }; +} + function getLatestUserText(input: InputItem[] | undefined): string | undefined { if (!Array.isArray(input)) return undefined; for (let i = input.length - 1; i >= 0; i--) { @@ -672,6 +709,7 @@ export async function transformRequestBody( fastSession?: boolean, fastSessionStrategy?: FastSessionStrategy, fastSessionMaxInputItems?: number, + deferFastSessionInputTrimming?: boolean, ): Promise; export async function transformRequestBody( bodyOrParams: RequestBody | TransformRequestBodyParams, @@ -681,6 +719,7 @@ export async function transformRequestBody( fastSession = false, fastSessionStrategy: FastSessionStrategy = "hybrid", fastSessionMaxInputItems = 30, + deferFastSessionInputTrimming = false, ): Promise { const useNamedParams = typeof codexInstructions === "undefined" && @@ -695,6 +734,7 @@ export async function transformRequestBody( let resolvedFastSession: boolean; let resolvedFastSessionStrategy: FastSessionStrategy; let resolvedFastSessionMaxInputItems: number; + let resolvedDeferFastSessionInputTrimming: boolean; if (useNamedParams) { const namedParams = bodyOrParams as TransformRequestBodyParams; @@ -705,6 +745,8 @@ export async function transformRequestBody( resolvedFastSession = namedParams.fastSession ?? false; resolvedFastSessionStrategy = namedParams.fastSessionStrategy ?? "hybrid"; resolvedFastSessionMaxInputItems = namedParams.fastSessionMaxInputItems ?? 30; + resolvedDeferFastSessionInputTrimming = + namedParams.deferFastSessionInputTrimming ?? false; } else { body = bodyOrParams as RequestBody; resolvedCodexInstructions = codexInstructions; @@ -713,6 +755,7 @@ export async function transformRequestBody( resolvedFastSession = fastSession; resolvedFastSessionStrategy = fastSessionStrategy; resolvedFastSessionMaxInputItems = fastSessionMaxInputItems; + resolvedDeferFastSessionInputTrimming = deferFastSessionInputTrimming; } if (!body || typeof body !== "object") { @@ -747,17 +790,17 @@ export async function transformRequestBody( const reasoningModel = shouldUseNormalizedReasoningModel ? normalizedModel : lookupModel; - const shouldApplyFastSessionTuning = - resolvedFastSession && - (resolvedFastSessionStrategy === "always" || - !isComplexFastSessionRequest(body, resolvedFastSessionMaxInputItems)); - const latestUserText = getLatestUserText(body.input); - const isTrivialTurn = isTrivialLatestPrompt(latestUserText ?? ""); + const fastSessionInputTrimPlan = resolveFastSessionInputTrimPlan( + body, + resolvedFastSession, + resolvedFastSessionStrategy, + resolvedFastSessionMaxInputItems, + ); + const shouldApplyFastSessionTuning = fastSessionInputTrimPlan.shouldApply; + const isTrivialTurn = fastSessionInputTrimPlan.isTrivialTurn; const shouldDisableToolsForTrivialTurn = shouldApplyFastSessionTuning && isTrivialTurn; - const shouldPreferLatestUserOnly = - shouldApplyFastSessionTuning && isTrivialTurn; // Codex required fields // ChatGPT backend REQUIRES store=false (confirmed via testing) @@ -789,10 +832,11 @@ export async function transformRequestBody( if (body.input && Array.isArray(body.input)) { let inputItems: InputItem[] = body.input; - if (shouldApplyFastSessionTuning) { + if (shouldApplyFastSessionTuning && !resolvedDeferFastSessionInputTrimming) { inputItems = trimInputForFastSession(inputItems, resolvedFastSessionMaxInputItems, { - preferLatestUserOnly: shouldPreferLatestUserOnly, + preferLatestUserOnly: + fastSessionInputTrimPlan.trim?.preferLatestUserOnly ?? false, }) ?? inputItems; } diff --git a/lib/request/response-compaction.ts b/lib/request/response-compaction.ts new file mode 100644 index 00000000..d61151fe --- /dev/null +++ b/lib/request/response-compaction.ts @@ -0,0 +1,158 @@ +import { logDebug, logWarn } from "../logger.js"; +import type { InputItem, RequestBody } from "../types.js"; +import { isRecord } from "../utils.js"; +import { getModelCapabilities } from "./helpers/model-map.js"; +import { trimInputForFastSession } from "./request-transformer.js"; + +export interface DeferredFastSessionInputTrim { + maxItems: number; + preferLatestUserOnly: boolean; +} + +export interface ResponseCompactionResult { + body: RequestBody; + mode: "compacted" | "trimmed" | "unchanged"; +} + +export interface ApplyResponseCompactionParams { + body: RequestBody; + requestUrl: string; + headers: Headers; + trim: DeferredFastSessionInputTrim; + fetchImpl: typeof fetch; + signal?: AbortSignal | null; + timeoutMs?: number; +} + +function isInputItemArray(value: unknown): value is InputItem[] { + return Array.isArray(value) && value.every((item) => isRecord(item)); +} + +function extractCompactedInput(payload: unknown): InputItem[] | undefined { + if (!isRecord(payload)) return undefined; + if (isInputItemArray(payload.output)) return payload.output; + if (isInputItemArray(payload.input)) return payload.input; + + const response = payload.response; + if (!isRecord(response)) return undefined; + if (isInputItemArray(response.output)) return response.output; + if (isInputItemArray(response.input)) return response.input; + return undefined; +} + +function buildCompactionUrl(requestUrl: string): string { + return requestUrl.endsWith("/compact") ? requestUrl : `${requestUrl}/compact`; +} + +function createFallbackBody( + body: RequestBody, + trim: DeferredFastSessionInputTrim, +): RequestBody | undefined { + if (!Array.isArray(body.input)) return undefined; + const trimmedInput = + trimInputForFastSession(body.input, trim.maxItems, { + preferLatestUserOnly: trim.preferLatestUserOnly, + }) ?? body.input; + + return trimmedInput === body.input ? undefined : { ...body, input: trimmedInput }; +} + +function createTimedAbortSignal( + signal: AbortSignal | null | undefined, + timeoutMs: number, +): { signal: AbortSignal; cleanup: () => void } { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(new Error("Response compaction timeout")); + }, timeoutMs); + + const onAbort = () => { + controller.abort(signal?.reason ?? new Error("Aborted")); + }; + + if (signal?.aborted) { + onAbort(); + } else if (signal) { + signal.addEventListener("abort", onAbort, { once: true }); + } + + return { + signal: controller.signal, + cleanup: () => { + clearTimeout(timeout); + signal?.removeEventListener("abort", onAbort); + }, + }; +} + +export async function applyResponseCompaction( + params: ApplyResponseCompactionParams, +): Promise { + const fallbackBody = createFallbackBody(params.body, params.trim); + if (!fallbackBody) { + return { body: params.body, mode: "unchanged" }; + } + + if (!getModelCapabilities(params.body.model).compaction) { + return { body: fallbackBody, mode: "trimmed" }; + } + + const compactionHeaders = new Headers(params.headers); + compactionHeaders.set("accept", "application/json"); + compactionHeaders.set("content-type", "application/json"); + const { signal, cleanup } = createTimedAbortSignal( + params.signal, + Math.max(250, params.timeoutMs ?? 4_000), + ); + + try { + const response = await params.fetchImpl(buildCompactionUrl(params.requestUrl), { + method: "POST", + headers: compactionHeaders, + body: JSON.stringify({ + model: params.body.model, + input: params.body.input, + }), + signal, + }); + + if (!response.ok) { + logWarn("Responses compaction request failed; using trim fallback.", { + status: response.status, + statusText: response.statusText, + model: params.body.model, + }); + return { body: fallbackBody, mode: "trimmed" }; + } + + const payload = (await response.json()) as unknown; + const compactedInput = extractCompactedInput(payload); + if (!compactedInput || compactedInput.length === 0) { + logWarn("Responses compaction returned no reusable input; using trim fallback.", { + model: params.body.model, + }); + return { body: fallbackBody, mode: "trimmed" }; + } + + logDebug("Applied server-side response compaction.", { + model: params.body.model, + originalInputLength: Array.isArray(params.body.input) ? params.body.input.length : 0, + compactedInputLength: compactedInput.length, + }); + return { body: { ...params.body, input: compactedInput }, mode: "compacted" }; + } catch (error) { + if (signal.aborted && params.signal?.aborted) { + throw params.signal.reason instanceof Error + ? params.signal.reason + : new Error("Aborted"); + } + + logWarn("Responses compaction failed; using trim fallback.", { + model: params.body.model, + error: error instanceof Error ? error.message : String(error), + }); + return { body: fallbackBody, mode: "trimmed" }; + } finally { + cleanup(); + } +} diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 613d6c93..ef1a6ead 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -5707,7 +5707,7 @@ describe("codex manager cli commands", () => { normalized: string; remapped: boolean; promptFamily: string; - capabilities: { toolSearch: boolean; computerUse: boolean }; + capabilities: { toolSearch: boolean; computerUse: boolean; compaction: boolean }; }; }; expect(payload.command).toBe("report"); @@ -5722,6 +5722,7 @@ describe("codex manager cli commands", () => { capabilities: { toolSearch: false, computerUse: false, + compaction: false, }, }); }); @@ -5760,7 +5761,7 @@ describe("codex manager cli commands", () => { normalized: string; remapped: boolean; promptFamily: string; - capabilities: { toolSearch: boolean; computerUse: boolean }; + capabilities: { toolSearch: boolean; computerUse: boolean; compaction: boolean }; }; }; expect(payload.modelSelection).toEqual({ @@ -5769,8 +5770,9 @@ describe("codex manager cli commands", () => { remapped: true, promptFamily: "gpt-5.2", capabilities: { - toolSearch: false, - computerUse: false, + toolSearch: true, + computerUse: true, + compaction: true, }, }); }); diff --git a/test/index.test.ts b/test/index.test.ts index 954e6621..0778f960 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -136,9 +136,13 @@ vi.mock("../lib/live-account-sync.js", () => ({ LiveAccountSync: liveAccountSyncCtorMock, })); -vi.mock("../lib/request/request-transformer.js", () => ({ - applyFastSessionDefaults: (config: T) => config, -})); +vi.mock("../lib/request/request-transformer.js", async () => { + const actual = await vi.importActual("../lib/request/request-transformer.js"); + return { + ...(actual as Record), + applyFastSessionDefaults: (config: T) => config, + }; +}); vi.mock("../lib/logger.js", () => ({ initLogger: vi.fn(), @@ -1636,6 +1640,59 @@ describe("OpenAIOAuthPlugin fetch handler", () => { expect(thirdHeaders.get("x-test-access-token")).toBe("access-alpha"); }); + it("compacts fast-session input before sending the upstream request when compaction succeeds", async () => { + const fetchHelpers = await import("../lib/request/fetch-helpers.js"); + const longInput = Array.from({ length: 12 }, (_value, index) => ({ + type: "message", + role: index === 0 ? "developer" : "user", + content: index === 0 ? "system prompt" : `message-${index}`, + })); + const compactedInput = [ + { + type: "message", + role: "assistant", + content: "compacted summary", + }, + ]; + + vi.mocked(fetchHelpers.transformRequestForCodex).mockResolvedValueOnce({ + updatedInit: { + method: "POST", + body: JSON.stringify({ model: "gpt-5-mini", input: longInput }), + }, + body: { model: "gpt-5-mini", input: longInput }, + deferredFastSessionInputTrim: { maxItems: 8, preferLatestUserOnly: false }, + }); + + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ output: compactedInput }), { status: 200 }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ content: "ok" }), { status: 200 }), + ); + + const { sdk } = await setupPlugin(); + const response = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5-mini", input: longInput }), + }); + + expect(response.status).toBe(200); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + expect(vi.mocked(globalThis.fetch).mock.calls[0]?.[0]).toBe( + "https://api.openai.com/v1/chat/compact", + ); + + const upstreamInit = vi.mocked(globalThis.fetch).mock.calls[1]?.[1] as RequestInit; + const upstreamBody = + typeof upstreamInit.body === "string" + ? (JSON.parse(upstreamInit.body) as { input?: unknown[] }) + : {}; + expect(upstreamBody.input).toEqual(compactedInput); + }); + it("uses the refreshed token email when checking entitlement blocks", async () => { const { AccountManager } = await import("../lib/accounts.js"); const manager = buildRoutingManager([ diff --git a/test/model-map.test.ts b/test/model-map.test.ts index 6ad16967..7d2f8adb 100644 --- a/test/model-map.test.ts +++ b/test/model-map.test.ts @@ -84,14 +84,22 @@ describe("model map", () => { expect(getModelCapabilities("gpt-5.4")).toEqual({ toolSearch: true, computerUse: true, + compaction: true, }); expect(getModelCapabilities("gpt-5.4-pro")).toEqual({ toolSearch: false, computerUse: true, + compaction: true, }); expect(getModelCapabilities("gpt-5-mini")).toEqual({ + toolSearch: true, + computerUse: true, + compaction: true, + }); + expect(getModelCapabilities("gpt-5-nano")).toEqual({ toolSearch: false, computerUse: false, + compaction: true, }); }); }); diff --git a/test/request-transformer.test.ts b/test/request-transformer.test.ts index 51eb1214..17efbbcf 100644 --- a/test/request-transformer.test.ts +++ b/test/request-transformer.test.ts @@ -653,9 +653,31 @@ describe('Request Transformer Module', () => { }, }, }; - const result = await transformRequestBody(body, codexInstructions); - expect(result.text?.verbosity).toBe('medium'); - expect(result.text?.format).toEqual(body.text?.format); + const result = await transformRequestBody(body, codexInstructions); + expect(result.text?.verbosity).toBe('medium'); + expect(result.text?.format).toEqual(body.text?.format); + }); + + it('defers fast-session input trimming when requested for downstream compaction', async () => { + const body: RequestBody = { + model: 'gpt-5.4', + input: Array.from({ length: 12 }, (_value, index) => ({ + type: 'message', + role: index === 0 ? 'developer' : 'user', + content: index === 0 ? 'system prompt' : `message-${index}`, + })), + }; + const result = await transformRequestBody( + body, + codexInstructions, + { global: {}, models: {} }, + true, + true, + 'always', + 8, + true, + ); + expect(result.input).toHaveLength(12); }); it('should set required Codex fields', async () => { diff --git a/test/response-compaction.test.ts b/test/response-compaction.test.ts new file mode 100644 index 00000000..649532ee --- /dev/null +++ b/test/response-compaction.test.ts @@ -0,0 +1,115 @@ +import { applyResponseCompaction } from "../lib/request/response-compaction.js"; +import type { RequestBody } from "../lib/types.js"; + +function buildInput(length: number) { + return Array.from({ length }, (_value, index) => ({ + type: "message", + role: index === 0 ? "developer" : "user", + content: index === 0 ? "system prompt" : `message-${index}`, + })); +} + +describe("response compaction", () => { + it("returns unchanged when the fast-session trim would be a no-op", async () => { + const body: RequestBody = { + model: "gpt-5.4", + input: buildInput(2), + }; + const fetchImpl = vi.fn(); + + const result = await applyResponseCompaction({ + body, + requestUrl: "https://chatgpt.com/backend-api/codex/responses", + headers: new Headers(), + trim: { maxItems: 8, preferLatestUserOnly: false }, + fetchImpl, + }); + + expect(result.mode).toBe("unchanged"); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(result.body.input).toEqual(body.input); + }); + + it("falls back to local trimming when the model does not support compaction", async () => { + const body: RequestBody = { + model: "gpt-5-codex", + input: buildInput(10), + }; + const fetchImpl = vi.fn(); + + const result = await applyResponseCompaction({ + body, + requestUrl: "https://chatgpt.com/backend-api/codex/responses", + headers: new Headers(), + trim: { maxItems: 8, preferLatestUserOnly: false }, + fetchImpl, + }); + + expect(result.mode).toBe("trimmed"); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(result.body.input).toHaveLength(8); + }); + + it("replaces request input with server-compacted output when available", async () => { + const compactedOutput = [ + { + type: "message", + role: "assistant", + content: "compacted summary", + }, + ]; + const body: RequestBody = { + model: "gpt-5-mini", + input: buildInput(12), + }; + const fetchImpl = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ output: compactedOutput }), { status: 200 }), + ); + + const result = await applyResponseCompaction({ + body, + requestUrl: "https://chatgpt.com/backend-api/codex/responses", + headers: new Headers({ accept: "text/event-stream" }), + trim: { maxItems: 8, preferLatestUserOnly: false }, + fetchImpl, + }); + + expect(result.mode).toBe("compacted"); + expect(result.body.input).toEqual(compactedOutput); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(fetchImpl).toHaveBeenCalledWith( + "https://chatgpt.com/backend-api/codex/responses/compact", + expect.objectContaining({ + method: "POST", + headers: expect.any(Headers), + }), + ); + + const requestInit = vi.mocked(fetchImpl).mock.calls[0]?.[1]; + const headers = new Headers(requestInit?.headers); + expect(headers.get("accept")).toBe("application/json"); + expect(headers.get("content-type")).toBe("application/json"); + }); + + it("falls back to local trimming when the compaction request fails", async () => { + const body: RequestBody = { + model: "gpt-5.4", + input: buildInput(12), + }; + const fetchImpl = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: { message: "nope" } }), { status: 404 }), + ); + + const result = await applyResponseCompaction({ + body, + requestUrl: "https://chatgpt.com/backend-api/codex/responses", + headers: new Headers(), + trim: { maxItems: 8, preferLatestUserOnly: false }, + fetchImpl, + }); + + expect(result.mode).toBe("trimmed"); + expect(result.body.input).toHaveLength(8); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); +}); From 279128797f8b1e8c2b7ec57679f27007401e5347 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:45:24 +0800 Subject: [PATCH 354/376] fix gpt-5-mini tool capabilities --- lib/request/helpers/model-map.ts | 2 +- test/model-map.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/request/helpers/model-map.ts b/lib/request/helpers/model-map.ts index b623c845..4f303ae0 100644 --- a/lib/request/helpers/model-map.ts +++ b/lib/request/helpers/model-map.ts @@ -159,7 +159,7 @@ export const MODEL_PROFILES: Record = { promptFamily: "gpt-5.2", defaultReasoningEffort: "medium", supportedReasoningEfforts: ["medium"], - capabilities: TOOL_CAPABILITIES.full, + capabilities: TOOL_CAPABILITIES.compactOnly, }, "gpt-5-nano": { normalizedModel: "gpt-5-nano", diff --git a/test/model-map.test.ts b/test/model-map.test.ts index 7d2f8adb..6fe07b56 100644 --- a/test/model-map.test.ts +++ b/test/model-map.test.ts @@ -92,8 +92,8 @@ describe("model map", () => { compaction: true, }); expect(getModelCapabilities("gpt-5-mini")).toEqual({ - toolSearch: true, - computerUse: true, + toolSearch: false, + computerUse: false, compaction: true, }); expect(getModelCapabilities("gpt-5-nano")).toEqual({ From 105ea6f35329e93a2777a3d8a74f5ba701057483 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:05:29 +0800 Subject: [PATCH 355/376] Fix gpt-5-mini capability assertion --- test/codex-manager-cli.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index ef1a6ead..5d41d384 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -5770,8 +5770,8 @@ describe("codex manager cli commands", () => { remapped: true, promptFamily: "gpt-5.2", capabilities: { - toolSearch: true, - computerUse: true, + toolSearch: false, + computerUse: false, compaction: true, }, }); From 28e3885641ad9c42ad007deba31d6c84080d8d58 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:17:19 +0800 Subject: [PATCH 356/376] Avoid repeated compaction after account rotation --- index.ts | 8 +++-- test/index.test.ts | 90 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/index.ts b/index.ts index 7fede6dd..42f02245 100644 --- a/index.ts +++ b/index.ts @@ -1357,7 +1357,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ); let requestInit = transformation?.updatedInit ?? baseInit; let transformedBody: RequestBody | undefined = transformation?.body; - const deferredFastSessionInputTrim = + let pendingFastSessionInputTrim = transformation?.deferredFastSessionInputTrim; const promptCacheKey = transformedBody?.prompt_cache_key; let model = transformedBody?.model; @@ -1676,12 +1676,14 @@ accountAttemptLoop: while (attempted.size < Math.max(1, accountCount)) { promptCacheKey: effectivePromptCacheKey, }, ); - if (transformedBody && deferredFastSessionInputTrim) { + if (transformedBody && pendingFastSessionInputTrim) { + const activeFastSessionInputTrim = pendingFastSessionInputTrim; + pendingFastSessionInputTrim = undefined; const compactionResult = await applyResponseCompaction({ body: transformedBody, requestUrl: url, headers, - trim: deferredFastSessionInputTrim, + trim: activeFastSessionInputTrim, fetchImpl: async (requestUrl, requestInit) => { const normalizedCompactionUrl = typeof requestUrl === "string" diff --git a/test/index.test.ts b/test/index.test.ts index 0778f960..95b8a0cb 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1693,6 +1693,96 @@ describe("OpenAIOAuthPlugin fetch handler", () => { expect(upstreamBody.input).toEqual(compactedInput); }); + it("does not rerun fast-session compaction after rotating to another account", async () => { + const { AccountManager } = await import("../lib/accounts.js"); + const fetchHelpers = await import("../lib/request/fetch-helpers.js"); + const longInput = Array.from({ length: 12 }, (_value, index) => ({ + type: "message", + role: index === 0 ? "developer" : "user", + content: index === 0 ? "system prompt" : `message-${index}`, + })); + const partiallyCompactedInput = Array.from({ length: 10 }, (_value, index) => ({ + type: "message", + role: index === 0 ? "developer" : "user", + content: index === 0 ? "compacted system prompt" : `compacted-${index}`, + })); + const manager = buildRoutingManager([ + { + index: 0, + accountId: "token-primary", + accountIdSource: "token", + email: "alpha@example.com", + refreshToken: "refresh-1", + accessToken: "access-alpha", + }, + { + index: 1, + accountId: "workspace-fallback", + accountIdSource: "org", + email: "beta@example.com", + refreshToken: "refresh-2", + accessToken: "access-beta", + }, + ]); + vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValueOnce(manager as never); + vi.mocked(fetchHelpers.transformRequestForCodex).mockResolvedValueOnce({ + updatedInit: { + method: "POST", + body: JSON.stringify({ model: "gpt-5-mini", input: longInput }), + }, + body: { model: "gpt-5-mini", input: longInput }, + deferredFastSessionInputTrim: { maxItems: 8, preferLatestUserOnly: false }, + }); + vi.mocked(fetchHelpers.createCodexHeaders).mockImplementation( + (_init, accountId, accessToken) => + new Headers({ + "x-test-account-id": String(accountId), + "x-test-access-token": String(accessToken), + }), + ); + globalThis.fetch = vi.fn(async (requestUrl, init) => { + const normalizedUrl = + typeof requestUrl === "string" ? requestUrl : String(requestUrl); + if (normalizedUrl.endsWith("/compact")) { + return new Response(JSON.stringify({ output: partiallyCompactedInput }), { + status: 200, + }); + } + + const headers = new Headers(init?.headers); + if (headers.get("x-test-access-token") === "access-alpha") { + throw new Error("Network timeout"); + } + + return new Response(JSON.stringify({ content: "ok" }), { status: 200 }); + }); + + const { sdk } = await setupPlugin(); + const response = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5-mini", input: longInput }), + }); + + expect(response.status).toBe(200); + const fetchCalls = vi.mocked(globalThis.fetch).mock.calls; + const compactionCalls = fetchCalls.filter(([requestUrl]) => + String(requestUrl).endsWith("/compact"), + ); + expect(compactionCalls).toHaveLength(1); + + const finalCall = fetchCalls[fetchCalls.length - 1]; + const finalHeaders = new Headers(finalCall?.[1]?.headers); + expect(finalHeaders.get("x-test-account-id")).toBe("workspace-fallback"); + expect(finalHeaders.get("x-test-access-token")).toBe("access-beta"); + + const finalUpstreamInit = finalCall?.[1] as RequestInit; + const finalUpstreamBody = + typeof finalUpstreamInit.body === "string" + ? (JSON.parse(finalUpstreamInit.body) as { input?: unknown[] }) + : {}; + expect(finalUpstreamBody.input).toEqual(partiallyCompactedInput); + }); + it("uses the refreshed token email when checking entitlement blocks", async () => { const { AccountManager } = await import("../lib/accounts.js"); const manager = buildRoutingManager([ From 405bd240cefa32e13579e94fb628b1abe4732391 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:25:15 +0800 Subject: [PATCH 357/376] Handle compaction URLs with query params --- lib/request/response-compaction.ts | 7 ++++++- test/response-compaction.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lib/request/response-compaction.ts b/lib/request/response-compaction.ts index d61151fe..82d6f5f8 100644 --- a/lib/request/response-compaction.ts +++ b/lib/request/response-compaction.ts @@ -41,7 +41,12 @@ function extractCompactedInput(payload: unknown): InputItem[] | undefined { } function buildCompactionUrl(requestUrl: string): string { - return requestUrl.endsWith("/compact") ? requestUrl : `${requestUrl}/compact`; + const queryIndex = requestUrl.indexOf("?"); + const baseUrl = queryIndex === -1 ? requestUrl : requestUrl.slice(0, queryIndex); + if (baseUrl.endsWith("/compact")) return requestUrl; + return queryIndex === -1 + ? `${requestUrl}/compact` + : `${baseUrl}/compact${requestUrl.slice(queryIndex)}`; } function createFallbackBody( diff --git a/test/response-compaction.test.ts b/test/response-compaction.test.ts index 649532ee..38cf7261 100644 --- a/test/response-compaction.test.ts +++ b/test/response-compaction.test.ts @@ -91,6 +91,29 @@ describe("response compaction", () => { expect(headers.get("content-type")).toBe("application/json"); }); + it("inserts /compact before query params in the compaction request URL", async () => { + const body: RequestBody = { + model: "gpt-5-mini", + input: buildInput(12), + }; + const fetchImpl = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ output: buildInput(8) }), { status: 200 }), + ); + + await applyResponseCompaction({ + body, + requestUrl: "https://chatgpt.com/backend-api/codex/responses?stream=true", + headers: new Headers(), + trim: { maxItems: 8, preferLatestUserOnly: false }, + fetchImpl, + }); + + expect(fetchImpl).toHaveBeenCalledWith( + "https://chatgpt.com/backend-api/codex/responses/compact?stream=true", + expect.any(Object), + ); + }); + it("falls back to local trimming when the compaction request fails", async () => { const body: RequestBody = { model: "gpt-5.4", From 1c62b82b5e249351a5ddbe0336b423eae6037220 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 17:27:07 +0800 Subject: [PATCH 358/376] Type GPT-5.4 hosted tool definitions --- lib/request/helpers/tool-utils.ts | 89 ++++++++++++++++-------------- lib/request/request-transformer.ts | 71 ++++++++++++++++++++++++ lib/types.ts | 70 ++++++++++++++++++++++- test/public-api-contract.test.ts | 14 ++++- test/request-transformer.test.ts | 83 ++++++++++++++++++++++++++++ test/tool-utils.test.ts | 57 +++++++++++++++++++ 6 files changed, 340 insertions(+), 44 deletions(-) diff --git a/lib/request/helpers/tool-utils.ts b/lib/request/helpers/tool-utils.ts index 7c14ec78..414f166c 100644 --- a/lib/request/helpers/tool-utils.ts +++ b/lib/request/helpers/tool-utils.ts @@ -1,20 +1,9 @@ import { isRecord } from "../../utils.js"; - -export interface ToolFunction { - name: string; - description?: string; - parameters?: { - type: "object"; - properties?: Record; - required?: string[]; - [key: string]: unknown; - }; -} - -export interface Tool { - type: "function"; - function: ToolFunction; -} +import type { + FunctionToolDefinition, + RequestToolDefinition, + ToolParametersSchema, +} from "../../types.js"; function cloneRecord(value: Record): Record { return JSON.parse(JSON.stringify(value)) as Record; @@ -36,36 +25,54 @@ function cloneRecord(value: Record): Record { export function cleanupToolDefinitions(tools: unknown): unknown { if (!Array.isArray(tools)) return tools; - return tools.map((tool) => { - if (!isRecord(tool) || tool.type !== "function") { - return tool; - } - const functionDef = tool.function; - if (!isRecord(functionDef)) { - return tool; - } - const parameters = functionDef.parameters; - if (!isRecord(parameters)) { - return tool; - } + return tools.map((tool) => cleanupToolDefinition(tool)); +} - // Clone only the schema tree we mutate to avoid heavy deep cloning of entire tools. - let cleanedParameters: Record; - try { - cleanedParameters = cloneRecord(parameters); - } catch { - return tool; - } - cleanupSchema(cleanedParameters); +function cleanupToolDefinition(tool: unknown): unknown { + if (!isRecord(tool)) { + return tool; + } + if (tool.type === "function") { + return cleanupFunctionTool(tool as FunctionToolDefinition); + } + + if (tool.type === "namespace" && Array.isArray(tool.tools)) { return { ...tool, - function: { - ...functionDef, - parameters: cleanedParameters, - }, + tools: tool.tools.map((nestedTool) => cleanupToolDefinition(nestedTool)) as RequestToolDefinition[], }; - }); + } + + return tool; +} + +function cleanupFunctionTool(tool: FunctionToolDefinition): FunctionToolDefinition { + const functionDef = tool.function; + if (!isRecord(functionDef)) { + return tool; + } + const parameters = functionDef.parameters; + if (!isRecord(parameters)) { + return tool; + } + + // Clone only the schema tree we mutate to avoid heavy deep cloning of entire tools. + let cleanedParameters: Record; + try { + cleanedParameters = cloneRecord(parameters); + } catch { + return tool; + } + cleanupSchema(cleanedParameters); + + return { + ...tool, + function: { + ...functionDef, + parameters: cleanedParameters as ToolParametersSchema, + }, + }; } /** diff --git a/lib/request/request-transformer.ts b/lib/request/request-transformer.ts index 5a407f55..a117ae73 100644 --- a/lib/request/request-transformer.ts +++ b/lib/request/request-transformer.ts @@ -3,6 +3,7 @@ import { TOOL_REMAP_MESSAGE } from "../prompts/codex.js"; import { CODEX_HOST_BRIDGE } from "../prompts/codex-host-bridge.js"; import { getHostCodexPrompt } from "../prompts/host-codex-prompt.js"; import { + getModelCapabilities, getModelProfile, resolveNormalizedModel, type ModelReasoningEffort, @@ -24,6 +25,10 @@ import type { type CollaborationMode = "plan" | "default" | "unknown"; type FastSessionStrategy = "hybrid" | "always"; type SupportedReasoningSummary = "auto" | "concise" | "detailed"; +type ToolCapabilityRemovalCounts = { + toolSearch: number; + computerUse: number; +}; export interface TransformRequestBodyParams { body: RequestBody; @@ -297,6 +302,71 @@ function sanitizePlanOnlyTools(tools: unknown, mode: CollaborationMode): unknown return filtered; } +const COMPUTER_TOOL_TYPES = new Set(["computer", "computer_use_preview"]); + +function sanitizeModelIncompatibleTools(tools: unknown, model: string | undefined): unknown { + if (!Array.isArray(tools)) return tools; + + const capabilities = getModelCapabilities(model); + const removed: ToolCapabilityRemovalCounts = { + toolSearch: 0, + computerUse: 0, + }; + const filtered = tools + .map((tool) => sanitizeModelIncompatibleToolEntry(tool, capabilities, removed)) + .filter((tool) => tool !== null); + + if (removed.toolSearch > 0) { + logWarn( + `Removed ${removed.toolSearch} tool_search definition(s) because ${model ?? "the selected model"} does not support tool search`, + ); + } + if (removed.computerUse > 0) { + logWarn( + `Removed ${removed.computerUse} computer tool definition(s) because ${model ?? "the selected model"} does not support computer use`, + ); + } + + return filtered; +} + +function sanitizeModelIncompatibleToolEntry( + tool: unknown, + capabilities: ReturnType, + removed: ToolCapabilityRemovalCounts, +): unknown | null { + if (!tool || typeof tool !== "object") { + return tool; + } + + const record = tool as Record; + const type = typeof record.type === "string" ? record.type : ""; + if (type === "tool_search" && !capabilities.toolSearch) { + removed.toolSearch += 1; + return null; + } + if (COMPUTER_TOOL_TYPES.has(type) && !capabilities.computerUse) { + removed.computerUse += 1; + return null; + } + if (type === "namespace" && Array.isArray(record.tools)) { + const nestedTools = record.tools + .map((nestedTool) => sanitizeModelIncompatibleToolEntry(nestedTool, capabilities, removed)) + .filter((nestedTool) => nestedTool !== null); + if (nestedTools.length === 0) { + return null; + } + if (nestedTools.length === record.tools.length) { + return tool; + } + return { + ...record, + tools: nestedTools, + }; + } + return tool; +} + /** * Configure reasoning parameters based on model variant and user config * @@ -831,6 +901,7 @@ export async function transformRequestBody( if (body.tools) { body.tools = cleanupToolDefinitions(body.tools); body.tools = sanitizePlanOnlyTools(body.tools, collaborationMode); + body.tools = sanitizeModelIncompatibleTools(body.tools, body.model); } body.instructions = shouldApplyFastSessionTuning diff --git a/lib/types.ts b/lib/types.ts index 17323401..589ff556 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -40,6 +40,74 @@ export interface ReasoningConfig { summary: "auto" | "concise" | "detailed"; } +export interface ToolParametersSchema { + type: "object"; + properties?: Record; + required?: string[]; + [key: string]: unknown; +} + +export interface ToolFunction { + name: string; + description?: string; + parameters?: ToolParametersSchema; + [key: string]: unknown; +} + +export interface FunctionToolDefinition { + type: "function"; + function: ToolFunction; + defer_loading?: boolean; + [key: string]: unknown; +} + +export interface ToolSearchToolDefinition { + type: "tool_search"; + max_num_results?: number; + search_context_size?: "low" | "medium" | "high"; + filters?: Record; + [key: string]: unknown; +} + +export interface RemoteMcpToolDefinition { + type: "mcp"; + server_label?: string; + server_url?: string; + connector_id?: string; + headers?: Record; + allowed_tools?: string[]; + require_approval?: "never" | "always" | "auto" | Record; + defer_loading?: boolean; + [key: string]: unknown; +} + +export interface ComputerUseToolDefinition { + type: "computer" | "computer_use_preview"; + display_width?: number; + display_height?: number; + environment?: string; + [key: string]: unknown; +} + +export interface ToolNamespaceDefinition { + type: "namespace"; + name?: string; + description?: string; + tools?: RequestToolDefinition[]; + [key: string]: unknown; +} + +export type RequestToolDefinition = + | FunctionToolDefinition + | ToolSearchToolDefinition + | RemoteMcpToolDefinition + | ComputerUseToolDefinition + | ToolNamespaceDefinition + | { + type?: string; + [key: string]: unknown; + }; + export type TextFormatConfig = | { type: "text"; @@ -125,7 +193,7 @@ export interface RequestBody { stream?: boolean; instructions?: string; input?: InputItem[]; - tools?: unknown; + tools?: RequestToolDefinition[] | unknown; reasoning?: Partial; text?: { verbosity?: "low" | "medium" | "high"; diff --git a/test/public-api-contract.test.ts b/test/public-api-contract.test.ts index a9d9a484..8434c1a2 100644 --- a/test/public-api-contract.test.ts +++ b/test/public-api-contract.test.ts @@ -12,7 +12,7 @@ import { getRateLimitBackoffWithReason, } from "../lib/request/rate-limit-backoff.js"; import { transformRequestBody } from "../lib/request/request-transformer.js"; -import type { RequestBody } from "../lib/types.js"; +import type { RequestBody, RequestToolDefinition } from "../lib/types.js"; describe("public api contract", () => { it("keeps root plugin exports aligned", async () => { @@ -114,9 +114,18 @@ describe("public api contract", () => { expect(rateNamed).toEqual(ratePositional); const baseBody: RequestBody = { - model: "gpt-5-codex", + model: "gpt-5.4", input: [{ type: "message", role: "user", content: "hi" }], prompt_cache_retention: "24h", + tools: [ + { type: "tool_search", max_num_results: 2 }, + { + type: "mcp", + server_label: "docs", + server_url: "https://mcp.example.com", + defer_loading: true, + }, + ] satisfies RequestToolDefinition[], text: { format: { type: "json_schema", @@ -145,5 +154,6 @@ describe("public api contract", () => { expect(transformedNamed.prompt_cache_retention).toBe(baseBody.prompt_cache_retention); expect(transformedPositional.text?.format).toEqual(baseBody.text?.format); expect(transformedNamed.text?.format).toEqual(baseBody.text?.format); + expect(transformedNamed.tools).toEqual(baseBody.tools); }); }); diff --git a/test/request-transformer.test.ts b/test/request-transformer.test.ts index af8d5c84..0e7a01ac 100644 --- a/test/request-transformer.test.ts +++ b/test/request-transformer.test.ts @@ -1999,6 +1999,89 @@ describe('Request Transformer Module', () => { expect(toolNames).toEqual(['request_user_input']); }); + + it('removes tool_search tools when the selected model lacks search capability', async () => { + const body: RequestBody = { + model: 'gpt-5-nano', + input: [], + tools: [ + { type: 'tool_search', max_num_results: 3 }, + { + type: 'mcp', + server_label: 'docs', + server_url: 'https://mcp.example.com', + defer_loading: true, + }, + ] as any, + }; + + const result = await transformRequestBody(body, codexInstructions); + expect(result.tools).toEqual([ + { + type: 'mcp', + server_label: 'docs', + server_url: 'https://mcp.example.com', + defer_loading: true, + }, + ]); + }); + + it('removes computer tools when the selected model lacks computer-use capability', async () => { + const body: RequestBody = { + model: 'gpt-5-nano', + input: [], + tools: [ + { + type: 'computer_use_preview', + display_width: 1024, + display_height: 768, + environment: 'browser', + }, + { type: 'tool_search', max_num_results: 1 }, + ] as any, + }; + + const result = await transformRequestBody(body, codexInstructions); + expect(result.tools).toEqual([]); + }); + + it('filters unsupported namespace tool entries while keeping supported remote MCP tools', async () => { + const body: RequestBody = { + model: 'gpt-5-nano', + input: [], + tools: [ + { + type: 'namespace', + name: 'search_suite', + tools: [ + { type: 'tool_search', max_num_results: 2 }, + { + type: 'mcp', + server_label: 'remote-docs', + server_url: 'https://mcp.example.com', + defer_loading: true, + }, + ], + }, + ] as any, + }; + + const result = await transformRequestBody(body, codexInstructions); + expect(result.tools).toEqual([ + { + type: 'namespace', + name: 'search_suite', + tools: [ + { + type: 'mcp', + server_label: 'remote-docs', + server_url: 'https://mcp.example.com', + defer_loading: true, + }, + ], + }, + ]); + }); }); // NEW: Integration tests for all config scenarios diff --git a/test/tool-utils.test.ts b/test/tool-utils.test.ts index 31988238..b150c44e 100644 --- a/test/tool-utils.test.ts +++ b/test/tool-utils.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { cleanupToolDefinitions } from "../lib/request/helpers/tool-utils.js"; +import type { RequestToolDefinition } from "../lib/types.js"; describe("cleanupToolDefinitions", () => { it("returns non-array input unchanged", () => { @@ -13,6 +14,27 @@ describe("cleanupToolDefinitions", () => { expect(cleanupToolDefinitions(tools)).toEqual(tools); }); + it("preserves typed GPT-5.4 hosted tools unchanged", () => { + const tools: RequestToolDefinition[] = [ + { type: "tool_search", max_num_results: 3, search_context_size: "medium" }, + { + type: "mcp", + server_label: "docs", + server_url: "https://mcp.example.com", + defer_loading: true, + require_approval: "never", + }, + { + type: "computer_use_preview", + display_width: 1024, + display_height: 768, + environment: "browser", + }, + ]; + + expect(cleanupToolDefinitions(tools)).toEqual(tools); + }); + it("treats array parameters as non-records and leaves tool unchanged", () => { const tools = [{ type: "function", @@ -619,4 +641,39 @@ describe("cleanupToolDefinitions", () => { const props = result[0].function.parameters.properties as Record; expect(props.valid).toEqual({ type: "string" }); }); + + it("recursively cleans nested function tools inside namespace bundles", () => { + const tools: RequestToolDefinition[] = [ + { + type: "namespace", + name: "search_bundle", + tools: [ + { + type: "function", + function: { + name: "lookup", + parameters: { + type: "object", + properties: {}, + additionalProperties: false, + }, + }, + }, + { type: "tool_search", max_num_results: 2 }, + ], + }, + ]; + + const result = cleanupToolDefinitions(tools) as typeof tools; + const namespaceTools = result[0].tools ?? []; + const nestedFunction = namespaceTools[0] as Extract; + expect(nestedFunction.function.parameters?.additionalProperties).toBeUndefined(); + expect(nestedFunction.function.parameters?.properties).toEqual({ + _placeholder: { + type: "boolean", + description: "This property is a placeholder and should be ignored.", + }, + }); + expect(namespaceTools[1]).toEqual({ type: "tool_search", max_num_results: 2 }); + }); }); From 64e239179773cbfb479b1aca4e08c946b7637d29 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 17:58:51 +0800 Subject: [PATCH 359/376] add opt-in responses background mode guardrails --- docs/development/CONFIG_FIELDS.md | 3 ++ docs/reference/public-api.md | 6 ++- index.ts | 2 + lib/config.ts | 18 ++++++++ lib/request/fetch-helpers.ts | 5 ++- lib/request/request-transformer.ts | 59 +++++++++++++++++++++++--- lib/schemas.ts | 1 + lib/types.ts | 1 + test/fetch-helpers.test.ts | 12 ++++++ test/plugin-config.test.ts | 25 +++++++++++ test/public-api-contract.test.ts | 12 ++++++ test/request-transformer.test.ts | 68 ++++++++++++++++++++++++++++++ 12 files changed, 203 insertions(+), 9 deletions(-) diff --git a/docs/development/CONFIG_FIELDS.md b/docs/development/CONFIG_FIELDS.md index f345e793..5349bcc0 100644 --- a/docs/development/CONFIG_FIELDS.md +++ b/docs/development/CONFIG_FIELDS.md @@ -76,10 +76,13 @@ Used only for host plugin mode through the host runtime config file. | `sessionRecovery` | `true` | | `autoResume` | `true` | | `responseContinuation` | `false` | +| `backgroundResponses` | `false` | | `proactiveRefreshGuardian` | `true` | | `proactiveRefreshIntervalMs` | `60000` | | `proactiveRefreshBufferMs` | `300000` | +`backgroundResponses` is an opt-in compatibility switch for Responses API `background: true` requests. When enabled, those requests become stateful (`store=true`) instead of following the default stateless Codex routing. + ### Storage / Sync | Key | Default | diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md index a31a12c1..db0e1b84 100644 --- a/docs/reference/public-api.md +++ b/docs/reference/public-api.md @@ -67,7 +67,11 @@ Positional signatures are preserved for backward compatibility. The request-transform layer intentionally preserves and/or normalizes modern Responses API fields that callers may already send through the host SDK. -- The plugin preserves `previous_response_id` when explicitly provided and may auto-fill it from plugin continuation state when `pluginConfig.responseContinuation` is enabled, maintains `text.format` when verbosity defaults are applied, and honors `prompt_cache_retention` from the request body before falling back to `providerOptions.openai.promptCacheRetention` or user config defaults. +- `previous_response_id` is preserved when explicitly provided and may be auto-filled from plugin continuation state when `pluginConfig.responseContinuation` is enabled. +- `background` is typed as a first-class request field. It remains disabled by default and only passes through when `pluginConfig.backgroundResponses` or `CODEX_AUTH_BACKGROUND_RESPONSES=1` explicitly enables the stateful compatibility path. +- `text.format` is preserved when verbosity defaults are applied, so structured-output contracts continue to flow through untouched. +- `prompt_cache_retention` is preserved from the request body and can fall back to `providerOptions.openai.promptCacheRetention` or user config defaults. +- Background-mode requests force `store=true`, keep caller-supplied input item IDs, and skip stateless-only defaults such as `reasoning.encrypted_content` injection and fast-session trimming. - Hosted built-in tool definitions are typed and supported for: - `tool_search` - remote `mcp` diff --git a/index.ts b/index.ts index ec31a07b..c431282b 100644 --- a/index.ts +++ b/index.ts @@ -66,6 +66,7 @@ import { getLiveAccountSyncDebounceMs, getLiveAccountSyncPollMs, getResponseContinuation, + getBackgroundResponses, getSessionAffinity, getSessionAffinityTtlMs, getSessionAffinityMaxEntries, @@ -1371,6 +1372,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { fastSessionStrategy, fastSessionMaxInputItems, deferFastSessionInputTrimming: fastSessionEnabled, + allowBackgroundResponses: getBackgroundResponses(pluginConfig), }, ); let requestInit = transformation?.updatedInit ?? baseInit; diff --git a/lib/config.ts b/lib/config.ts index 910ed14b..e6e5350c 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -149,6 +149,7 @@ export const DEFAULT_PLUGIN_CONFIG: PluginConfig = { sessionAffinityTtlMs: 20 * 60_000, sessionAffinityMaxEntries: 512, responseContinuation: false, + backgroundResponses: false, proactiveRefreshGuardian: true, proactiveRefreshIntervalMs: 60_000, proactiveRefreshBufferMs: 5 * 60_000, @@ -936,6 +937,23 @@ export function getResponseContinuation(pluginConfig: PluginConfig): boolean { ); } +/** + * Controls whether the plugin may preserve explicit Responses API background requests. + * + * Background mode is disabled by default because the normal Codex request path is stateless (`store=false`). + * When enabled, callers may opt into `background: true`, which switches the request to `store=true`. + * + * @param pluginConfig - The plugin configuration to consult for the setting + * @returns `true` if stateful background responses are allowed, `false` otherwise + */ +export function getBackgroundResponses(pluginConfig: PluginConfig): boolean { + return resolveBooleanSetting( + "CODEX_AUTH_BACKGROUND_RESPONSES", + pluginConfig.backgroundResponses, + false, + ); +} + /** * Controls whether the proactive refresh guardian is enabled. * diff --git a/lib/request/fetch-helpers.ts b/lib/request/fetch-helpers.ts index 348ea4f8..e3544362 100644 --- a/lib/request/fetch-helpers.ts +++ b/lib/request/fetch-helpers.ts @@ -663,6 +663,7 @@ export async function transformRequestForCodex( fastSessionStrategy?: "hybrid" | "always"; fastSessionMaxInputItems?: number; deferFastSessionInputTrimming?: boolean; + allowBackgroundResponses?: boolean; }, ): Promise { const hasParsedBody = @@ -719,6 +720,7 @@ export async function transformRequestForCodex( options?.fastSessionStrategy ?? "hybrid", options?.fastSessionMaxInputItems ?? 30, options?.deferFastSessionInputTrimming ?? false, + options?.allowBackgroundResponses ?? false, ); // Log transformed request @@ -740,7 +742,8 @@ export async function transformRequestForCodex( body: transformedBody, updatedInit: { ...(init ?? {}), body: JSON.stringify(transformedBody) }, deferredFastSessionInputTrim: - options?.deferFastSessionInputTrimming === true + options?.deferFastSessionInputTrimming === true && + transformedBody.background !== true ? fastSessionInputTrimPlan.trim : undefined, }; diff --git a/lib/request/request-transformer.ts b/lib/request/request-transformer.ts index a117ae73..fa2f601e 100644 --- a/lib/request/request-transformer.ts +++ b/lib/request/request-transformer.ts @@ -39,6 +39,7 @@ export interface TransformRequestBodyParams { fastSessionStrategy?: FastSessionStrategy; fastSessionMaxInputItems?: number; deferFastSessionInputTrimming?: boolean; + allowBackgroundResponses?: boolean; } const PLAN_MODE_ONLY_TOOLS = new Set(["request_user_input"]); @@ -229,6 +230,30 @@ function resolveInclude(modelConfig: ConfigOptions, body: RequestBody): string[] return include; } +function isBackgroundModeRequested(body: RequestBody): boolean { + return body.background === true; +} + +function assertBackgroundModeCompatibility( + body: RequestBody, + allowBackgroundResponses: boolean, +): boolean { + if (!isBackgroundModeRequested(body)) { + return false; + } + if (!allowBackgroundResponses) { + throw new Error( + "Responses background mode is disabled. Enable pluginConfig.backgroundResponses or CODEX_AUTH_BACKGROUND_RESPONSES=1 to opt in.", + ); + } + if (body.store === false || body.providerOptions?.openai?.store === false) { + throw new Error( + "Responses background mode requires store=true and cannot be combined with stateless store=false routing.", + ); + } + return true; +} + function parseCollaborationMode(value: string | undefined): CollaborationMode | undefined { if (!value) return undefined; const normalized = value.trim().toLowerCase(); @@ -466,8 +491,10 @@ function sanitizeReasoningSummary( */ export function filterInput( input: InputItem[] | undefined, + options?: { stripIds?: boolean }, ): InputItem[] | undefined { if (!Array.isArray(input)) return input; + const stripIds = options?.stripIds ?? true; const filtered: InputItem[] = []; for (const item of input) { if (!item || typeof item !== "object") { @@ -478,7 +505,7 @@ export function filterInput( continue; } // Strip IDs from all items (Codex API stateless mode). - if ("id" in item) { + if (stripIds && "id" in item) { const { id: _omit, ...itemWithoutId } = item; void _omit; filtered.push(itemWithoutId as InputItem); @@ -771,7 +798,7 @@ export function addToolRemapMessage( * NOTE: Configuration follows Codex CLI patterns instead of host defaults: * - host may set textVerbosity="low" for gpt-5, but Codex CLI uses "medium" * - host may exclude gpt-5-codex from reasoning configuration - * - This plugin uses store=false (stateless), requiring encrypted reasoning content + * - This plugin defaults to store=false (stateless), with an explicit opt-in for background mode * * @param body - Original request body * @param codexInstructions - Codex system instructions @@ -792,6 +819,7 @@ export async function transformRequestBody( fastSessionStrategy?: FastSessionStrategy, fastSessionMaxInputItems?: number, deferFastSessionInputTrimming?: boolean, + allowBackgroundResponses?: boolean, ): Promise; export async function transformRequestBody( bodyOrParams: RequestBody | TransformRequestBodyParams, @@ -802,6 +830,7 @@ export async function transformRequestBody( fastSessionStrategy: FastSessionStrategy = "hybrid", fastSessionMaxInputItems = 30, deferFastSessionInputTrimming = false, + allowBackgroundResponses = false, ): Promise { const useNamedParams = typeof codexInstructions === "undefined" && @@ -817,6 +846,7 @@ export async function transformRequestBody( let resolvedFastSessionStrategy: FastSessionStrategy; let resolvedFastSessionMaxInputItems: number; let resolvedDeferFastSessionInputTrimming: boolean; + let resolvedAllowBackgroundResponses: boolean; if (useNamedParams) { const namedParams = bodyOrParams as TransformRequestBodyParams; @@ -829,6 +859,8 @@ export async function transformRequestBody( resolvedFastSessionMaxInputItems = namedParams.fastSessionMaxInputItems ?? 30; resolvedDeferFastSessionInputTrimming = namedParams.deferFastSessionInputTrimming ?? false; + resolvedAllowBackgroundResponses = + namedParams.allowBackgroundResponses ?? false; } else { body = bodyOrParams as RequestBody; resolvedCodexInstructions = codexInstructions; @@ -838,6 +870,7 @@ export async function transformRequestBody( resolvedFastSessionStrategy = fastSessionStrategy; resolvedFastSessionMaxInputItems = fastSessionMaxInputItems; resolvedDeferFastSessionInputTrimming = deferFastSessionInputTrimming; + resolvedAllowBackgroundResponses = allowBackgroundResponses; } if (!body || typeof body !== "object") { @@ -872,21 +905,27 @@ export async function transformRequestBody( const reasoningModel = shouldUseNormalizedReasoningModel ? normalizedModel : lookupModel; + const backgroundModeRequested = assertBackgroundModeCompatibility( + body, + resolvedAllowBackgroundResponses, + ); const fastSessionInputTrimPlan = resolveFastSessionInputTrimPlan( body, resolvedFastSession, resolvedFastSessionStrategy, resolvedFastSessionMaxInputItems, ); - const shouldApplyFastSessionTuning = fastSessionInputTrimPlan.shouldApply; + const shouldApplyFastSessionTuning = + !backgroundModeRequested && fastSessionInputTrimPlan.shouldApply; const isTrivialTurn = fastSessionInputTrimPlan.isTrivialTurn; const shouldDisableToolsForTrivialTurn = shouldApplyFastSessionTuning && isTrivialTurn; // Codex required fields - // ChatGPT backend REQUIRES store=false (confirmed via testing) - body.store = false; + // ChatGPT backend normally requires store=false (confirmed via testing). + // Background mode is an explicit opt-in compatibility path that preserves stateful storage. + body.store = backgroundModeRequested ? true : false; // Always set stream=true for API - response handling detects original intent body.stream = true; @@ -934,7 +973,9 @@ export async function transformRequestBody( ); } - inputItems = filterInput(inputItems) ?? inputItems; + inputItems = filterInput(inputItems, { + stripIds: !backgroundModeRequested, + }) ?? inputItems; body.input = inputItems; // istanbul ignore next -- filterInput always removes IDs; this is defensive debug code @@ -1013,7 +1054,11 @@ export async function transformRequestBody( // Add include for encrypted reasoning content // Default: ["reasoning.encrypted_content"] (required for stateless operation with store=false) // This allows reasoning context to persist across turns without server-side storage - body.include = resolveInclude(modelConfig, body); + body.include = backgroundModeRequested + ? body.include ?? + body.providerOptions?.openai?.include ?? + modelConfig.include + : resolveInclude(modelConfig, body); // Remove unsupported parameters body.max_output_tokens = undefined; diff --git a/lib/schemas.ts b/lib/schemas.ts index 41585678..89c13669 100644 --- a/lib/schemas.ts +++ b/lib/schemas.ts @@ -48,6 +48,7 @@ export const PluginConfigSchema = z.object({ sessionAffinityTtlMs: z.number().min(1_000).optional(), sessionAffinityMaxEntries: z.number().min(8).optional(), responseContinuation: z.boolean().optional(), + backgroundResponses: z.boolean().optional(), proactiveRefreshGuardian: z.boolean().optional(), proactiveRefreshIntervalMs: z.number().min(5_000).optional(), proactiveRefreshBufferMs: z.number().min(30_000).optional(), diff --git a/lib/types.ts b/lib/types.ts index 589ff556..0fec156b 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -189,6 +189,7 @@ export interface InputItem { */ export interface RequestBody { model: string; + background?: boolean; store?: boolean; stream?: boolean; instructions?: string; diff --git a/test/fetch-helpers.test.ts b/test/fetch-helpers.test.ts index f90108e7..4050ea85 100644 --- a/test/fetch-helpers.test.ts +++ b/test/fetch-helpers.test.ts @@ -153,6 +153,18 @@ describe('Fetch Helpers Module', () => { expect(rewriteUrlForCodex(url)).toBe('https://chatgpt.com/backend-api/codex/responses'); }); + it('should preserve response subresource paths for background polling and cancel routes', () => { + const retrieveUrl = 'https://api.openai.com/v1/responses/resp_123'; + const cancelUrl = 'https://api.openai.com/v1/responses/resp_123/cancel'; + + expect(rewriteUrlForCodex(retrieveUrl)).toBe( + 'https://chatgpt.com/backend-api/v1/codex/responses/resp_123', + ); + expect(rewriteUrlForCodex(cancelUrl)).toBe( + 'https://chatgpt.com/backend-api/v1/codex/responses/resp_123/cancel', + ); + }); + it('should keep backend-api paths when URL is already on codex origin', () => { const url = 'https://chatgpt.com/backend-api/other'; expect(rewriteUrlForCodex(url)).toBe(url); diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index ed3bb8fa..e9c2762d 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -22,6 +22,7 @@ import { getPreemptiveQuotaRemainingPercent7d, getPreemptiveQuotaMaxDeferralMs, getResponseContinuation, + getBackgroundResponses, } from '../lib/config.js'; import type { PluginConfig } from '../lib/types.js'; import * as fs from 'node:fs'; @@ -132,6 +133,7 @@ describe('Plugin Configuration', () => { sessionAffinityTtlMs: 20 * 60_000, sessionAffinityMaxEntries: 512, responseContinuation: false, + backgroundResponses: false, proactiveRefreshGuardian: true, proactiveRefreshIntervalMs: 60_000, proactiveRefreshBufferMs: 5 * 60_000, @@ -191,6 +193,7 @@ describe('Plugin Configuration', () => { sessionAffinityTtlMs: 20 * 60_000, sessionAffinityMaxEntries: 512, responseContinuation: false, + backgroundResponses: false, proactiveRefreshGuardian: true, proactiveRefreshIntervalMs: 60_000, proactiveRefreshBufferMs: 5 * 60_000, @@ -447,6 +450,7 @@ describe('Plugin Configuration', () => { sessionAffinityTtlMs: 20 * 60_000, sessionAffinityMaxEntries: 512, responseContinuation: false, + backgroundResponses: false, proactiveRefreshGuardian: true, proactiveRefreshIntervalMs: 60_000, proactiveRefreshBufferMs: 5 * 60_000, @@ -512,6 +516,7 @@ describe('Plugin Configuration', () => { sessionAffinityTtlMs: 20 * 60_000, sessionAffinityMaxEntries: 512, responseContinuation: false, + backgroundResponses: false, proactiveRefreshGuardian: true, proactiveRefreshIntervalMs: 60_000, proactiveRefreshBufferMs: 5 * 60_000, @@ -571,6 +576,7 @@ describe('Plugin Configuration', () => { sessionAffinityTtlMs: 20 * 60_000, sessionAffinityMaxEntries: 512, responseContinuation: false, + backgroundResponses: false, proactiveRefreshGuardian: true, proactiveRefreshIntervalMs: 60_000, proactiveRefreshBufferMs: 5 * 60_000, @@ -683,6 +689,25 @@ describe('Plugin Configuration', () => { }); }); + describe('getBackgroundResponses', () => { + it('should default to false', () => { + delete process.env.CODEX_AUTH_BACKGROUND_RESPONSES; + expect(getBackgroundResponses({})).toBe(false); + }); + + it('should use config value when env var not set', () => { + delete process.env.CODEX_AUTH_BACKGROUND_RESPONSES; + expect(getBackgroundResponses({ backgroundResponses: true })).toBe(true); + }); + + it('should prioritize env override', () => { + process.env.CODEX_AUTH_BACKGROUND_RESPONSES = '1'; + expect(getBackgroundResponses({ backgroundResponses: false })).toBe(true); + process.env.CODEX_AUTH_BACKGROUND_RESPONSES = '0'; + expect(getBackgroundResponses({ backgroundResponses: true })).toBe(false); + }); + }); + describe('getCodexTuiV2', () => { it('should default to true', () => { delete process.env.CODEX_TUI_V2; diff --git a/test/public-api-contract.test.ts b/test/public-api-contract.test.ts index 6419b38b..a94c9565 100644 --- a/test/public-api-contract.test.ts +++ b/test/public-api-contract.test.ts @@ -115,6 +115,8 @@ describe("public api contract", () => { const baseBody: RequestBody = { model: "gpt-5.4", + background: true, + store: true, input: [{ type: "message", role: "user", content: "hi" }], prompt_cache_retention: "24h", tools: [ @@ -144,12 +146,22 @@ describe("public api contract", () => { const transformedPositional = await transformRequestBody( JSON.parse(JSON.stringify(baseBody)) as RequestBody, "codex", + undefined, + true, + false, + "hybrid", + 30, + false, + true, ); const transformedNamed = await transformRequestBody({ body: JSON.parse(JSON.stringify(baseBody)) as RequestBody, codexInstructions: "codex", + allowBackgroundResponses: true, }); expect(transformedNamed).toEqual(transformedPositional); expect(transformedNamed.tools).toEqual(baseBody.tools); + expect(transformedNamed.background).toBe(true); + expect(transformedNamed.store).toBe(true); }); }); diff --git a/test/request-transformer.test.ts b/test/request-transformer.test.ts index 74eb55ef..99b09ad1 100644 --- a/test/request-transformer.test.ts +++ b/test/request-transformer.test.ts @@ -2469,6 +2469,74 @@ describe('Request Transformer Module', () => { expect(named).toEqual(positional); }); + it('rejects background mode unless explicitly enabled', async () => { + await expect( + transformRequestBody( + { + model: 'gpt-5.4', + background: true, + input: [{ type: 'message', role: 'user', content: 'hello' }], + }, + codexInstructions, + ), + ).rejects.toThrowError( + 'Responses background mode is disabled. Enable pluginConfig.backgroundResponses or CODEX_AUTH_BACKGROUND_RESPONSES=1 to opt in.', + ); + }); + + it('preserves stateful request fields when background mode is enabled', async () => { + const result = await transformRequestBody( + { + model: 'gpt-5.4', + background: true, + input: [{ id: 'msg_stateful_123', type: 'message', role: 'user', content: 'hello' }], + }, + codexInstructions, + { global: {}, models: {} }, + true, + true, + 'always', + 12, + false, + true, + ); + + const userItem = result.input?.find((item) => item.role === 'user'); + expect(result.background).toBe(true); + expect(result.store).toBe(true); + expect(result.include).toBeUndefined(); + expect(result.text?.verbosity).toBe('medium'); + expect(userItem).toMatchObject({ + id: 'msg_stateful_123', + type: 'message', + role: 'user', + content: 'hello', + }); + }); + + it('rejects background mode when the request still forces store=false', async () => { + await expect( + transformRequestBody( + { + model: 'gpt-5.4', + background: true, + store: false, + input: [{ type: 'message', role: 'user', content: 'hello' }], + }, + codexInstructions, + { global: {}, models: {} }, + true, + false, + 'hybrid', + 30, + false, + true, + ), + ).rejects.toThrowError( + 'Responses background mode requires store=true and cannot be combined with stateless store=false routing.', + ); + }); + it('throws clear TypeError when named-parameter body is invalid', async () => { await expect( transformRequestBody({ From 38175ac2b2d8d986150fdeb44a038ced7493d31e Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:53:54 +0800 Subject: [PATCH 360/376] tighten hosted tool typing and filtering --- lib/request/helpers/tool-utils.ts | 8 +-- lib/request/request-transformer.ts | 82 ++++++++++++++++++++++------ lib/types.ts | 2 +- test/public-api-contract.test.ts | 4 +- test/request-transformer.test.ts | 86 +++++++++++++++++++++++++++++- 5 files changed, 160 insertions(+), 22 deletions(-) diff --git a/lib/request/helpers/tool-utils.ts b/lib/request/helpers/tool-utils.ts index 414f166c..fc7d4a94 100644 --- a/lib/request/helpers/tool-utils.ts +++ b/lib/request/helpers/tool-utils.ts @@ -22,13 +22,15 @@ function cloneRecord(value: Record): Record { * @param tools - Array of tool definitions * @returns Cleaned array of tool definitions */ -export function cleanupToolDefinitions(tools: unknown): unknown { - if (!Array.isArray(tools)) return tools; +export function cleanupToolDefinitions( + tools: RequestToolDefinition[] | undefined, +): RequestToolDefinition[] | undefined { + if (!Array.isArray(tools)) return undefined; return tools.map((tool) => cleanupToolDefinition(tool)); } -function cleanupToolDefinition(tool: unknown): unknown { +function cleanupToolDefinition(tool: RequestToolDefinition): RequestToolDefinition { if (!isRecord(tool)) { return tool; } diff --git a/lib/request/request-transformer.ts b/lib/request/request-transformer.ts index a117ae73..cf2b71b5 100644 --- a/lib/request/request-transformer.ts +++ b/lib/request/request-transformer.ts @@ -19,6 +19,7 @@ import type { InputItem, ReasoningConfig, RequestBody, + RequestToolDefinition, UserConfig, } from "../types.js"; @@ -279,20 +280,16 @@ function detectCollaborationMode(body: RequestBody): CollaborationMode { return "unknown"; } -function sanitizePlanOnlyTools(tools: unknown, mode: CollaborationMode): unknown { +function sanitizePlanOnlyTools( + tools: RequestToolDefinition[] | undefined, + mode: CollaborationMode, +): RequestToolDefinition[] | undefined { if (!Array.isArray(tools) || mode === "plan") return tools; let removed = 0; - const filtered = tools.filter((entry) => { - if (!entry || typeof entry !== "object") return true; - const functionDef = (entry as { function?: unknown }).function; - if (!functionDef || typeof functionDef !== "object") return true; - const name = (functionDef as { name?: unknown }).name; - if (typeof name !== "string") return true; - if (!PLAN_MODE_ONLY_TOOLS.has(name)) return true; - removed++; - return false; - }); + const filtered = tools + .map((entry) => sanitizePlanOnlyToolEntry(entry, mode, () => removed++)) + .filter((entry) => entry !== null); if (removed > 0) { logWarn( @@ -302,9 +299,55 @@ function sanitizePlanOnlyTools(tools: unknown, mode: CollaborationMode): unknown return filtered; } +function sanitizePlanOnlyToolEntry( + entry: RequestToolDefinition, + mode: CollaborationMode, + onRemoved: () => void, +): RequestToolDefinition | null { + if (!entry || typeof entry !== "object" || mode === "plan") { + return entry; + } + + const record = entry as Record; + if (record.type === "namespace" && Array.isArray(record.tools)) { + const namespaceTools = record.tools as RequestToolDefinition[]; + const nestedTools = namespaceTools + .map((nestedTool) => sanitizePlanOnlyToolEntry(nestedTool, mode, onRemoved)) + .filter((nestedTool) => nestedTool !== null); + const changed = + nestedTools.length !== namespaceTools.length || + nestedTools.some((nestedTool, index) => nestedTool !== namespaceTools[index]); + if (nestedTools.length === 0) { + onRemoved(); + return null; + } + if (!changed) { + return entry; + } + return { + ...record, + tools: nestedTools, + }; + } + + const functionDef = (entry as { function?: unknown }).function; + if (!functionDef || typeof functionDef !== "object") { + return entry; + } + const name = (functionDef as { name?: unknown }).name; + if (typeof name !== "string" || !PLAN_MODE_ONLY_TOOLS.has(name)) { + return entry; + } + onRemoved(); + return null; +} + const COMPUTER_TOOL_TYPES = new Set(["computer", "computer_use_preview"]); -function sanitizeModelIncompatibleTools(tools: unknown, model: string | undefined): unknown { +function sanitizeModelIncompatibleTools( + tools: RequestToolDefinition[] | undefined, + model: string | undefined, +): RequestToolDefinition[] | undefined { if (!Array.isArray(tools)) return tools; const capabilities = getModelCapabilities(model); @@ -331,10 +374,10 @@ function sanitizeModelIncompatibleTools(tools: unknown, model: string | undefine } function sanitizeModelIncompatibleToolEntry( - tool: unknown, + tool: RequestToolDefinition, capabilities: ReturnType, removed: ToolCapabilityRemovalCounts, -): unknown | null { +): RequestToolDefinition | null { if (!tool || typeof tool !== "object") { return tool; } @@ -350,13 +393,17 @@ function sanitizeModelIncompatibleToolEntry( return null; } if (type === "namespace" && Array.isArray(record.tools)) { - const nestedTools = record.tools + const namespaceTools = record.tools as RequestToolDefinition[]; + const nestedTools = namespaceTools .map((nestedTool) => sanitizeModelIncompatibleToolEntry(nestedTool, capabilities, removed)) .filter((nestedTool) => nestedTool !== null); + const changed = + nestedTools.length !== namespaceTools.length || + nestedTools.some((nestedTool, index) => nestedTool !== namespaceTools[index]); if (nestedTools.length === 0) { return null; } - if (nestedTools.length === record.tools.length) { + if (!changed) { return tool; } return { @@ -902,6 +949,9 @@ export async function transformRequestBody( body.tools = cleanupToolDefinitions(body.tools); body.tools = sanitizePlanOnlyTools(body.tools, collaborationMode); body.tools = sanitizeModelIncompatibleTools(body.tools, body.model); + if (Array.isArray(body.tools) && body.tools.length === 0) { + body.tools = undefined; + } } body.instructions = shouldApplyFastSessionTuning diff --git a/lib/types.ts b/lib/types.ts index 589ff556..25dab739 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -193,7 +193,7 @@ export interface RequestBody { stream?: boolean; instructions?: string; input?: InputItem[]; - tools?: RequestToolDefinition[] | unknown; + tools?: RequestToolDefinition[]; reasoning?: Partial; text?: { verbosity?: "low" | "medium" | "high"; diff --git a/test/public-api-contract.test.ts b/test/public-api-contract.test.ts index 8434c1a2..424fff03 100644 --- a/test/public-api-contract.test.ts +++ b/test/public-api-contract.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, expectTypeOf, it, vi } from "vitest"; import { HealthScoreTracker, TokenBucketTracker, @@ -45,6 +45,8 @@ describe("public api contract", () => { }); it("keeps positional and options-object overload behavior aligned", async () => { + expectTypeOf().toEqualTypeOf(); + const healthTracker = new HealthScoreTracker(); const tokenTracker = new TokenBucketTracker(); const accounts = [{ index: 0, isAvailable: true, lastUsed: 1_709_280_000_000 }]; diff --git a/test/request-transformer.test.ts b/test/request-transformer.test.ts index 0e7a01ac..83b1053e 100644 --- a/test/request-transformer.test.ts +++ b/test/request-transformer.test.ts @@ -2000,6 +2000,39 @@ describe('Request Transformer Module', () => { expect(toolNames).toEqual(['request_user_input']); }); + it('removes nested request_user_input tools outside plan collaboration mode', async () => { + const body: RequestBody = { + model: 'gpt-5', + input: [], + tools: [ + { + type: 'namespace', + name: 'planner', + tools: [ + { type: 'function', function: { name: 'request_user_input', parameters: { type: 'object', properties: {} } } }, + { type: 'function', function: { name: 'exec_command', parameters: { type: 'object', properties: {} } } }, + ], + }, + ] as any, + }; + + const result = await transformRequestBody(body, codexInstructions); + expect(result.tools).toEqual([ + { + type: 'namespace', + name: 'planner', + tools: [ + expect.objectContaining({ + type: 'function', + function: expect.objectContaining({ + name: 'exec_command', + }), + }), + ], + }, + ]); + }); + it('removes tool_search tools when the selected model lacks search capability', async () => { const body: RequestBody = { model: 'gpt-5-nano', @@ -2042,7 +2075,8 @@ describe('Request Transformer Module', () => { }; const result = await transformRequestBody(body, codexInstructions); - expect(result.tools).toEqual([]); + expect(result.tools).toBeUndefined(); + expect(result.input).toEqual([]); }); it('filters unsupported namespace tool entries while keeping supported remote MCP tools', async () => { @@ -2082,6 +2116,56 @@ describe('Request Transformer Module', () => { }, ]); }); + + it('filters unsupported tools from nested namespaces without dropping supported descendants', async () => { + const body: RequestBody = { + model: 'gpt-5-nano', + input: [], + tools: [ + { + type: 'namespace', + name: 'outer_suite', + tools: [ + { + type: 'namespace', + name: 'inner_suite', + tools: [ + { type: 'tool_search', max_num_results: 2 }, + { + type: 'mcp', + server_label: 'remote-docs', + server_url: 'https://mcp.example.com', + defer_loading: true, + }, + ], + }, + ], + }, + ] as any, + }; + + const result = await transformRequestBody(body, codexInstructions); + expect(result.tools).toEqual([ + { + type: 'namespace', + name: 'outer_suite', + tools: [ + { + type: 'namespace', + name: 'inner_suite', + tools: [ + { + type: 'mcp', + server_label: 'remote-docs', + server_url: 'https://mcp.example.com', + defer_loading: true, + }, + ], + }, + ], + }, + ]); + }); }); // NEW: Integration tests for all config scenarios From d5f9966c7b0c4d518ef27c43d67c6bc5d02c37ac Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 18:00:54 +0800 Subject: [PATCH 361/376] fix config mock for background response getter --- test/index.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/index.test.ts b/test/index.test.ts index 7882bc6f..6ea19cbc 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -100,6 +100,7 @@ vi.mock("../lib/config.js", () => ({ getSessionAffinityTtlMs: vi.fn(() => 1_200_000), getSessionAffinityMaxEntries: vi.fn(() => 512), getResponseContinuation: vi.fn(() => false), + getBackgroundResponses: vi.fn(() => false), getProactiveRefreshGuardian: () => false, getProactiveRefreshIntervalMs: () => 60000, getProactiveRefreshBufferMs: () => 300000, From b3439758033a38b10834ff153f1ad1d8ec2abd7b Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:06:06 +0800 Subject: [PATCH 362/376] Fix plan-only tool removal counting --- lib/request/request-transformer.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/request/request-transformer.ts b/lib/request/request-transformer.ts index cf2b71b5..c344ebca 100644 --- a/lib/request/request-transformer.ts +++ b/lib/request/request-transformer.ts @@ -318,7 +318,6 @@ function sanitizePlanOnlyToolEntry( nestedTools.length !== namespaceTools.length || nestedTools.some((nestedTool, index) => nestedTool !== namespaceTools[index]); if (nestedTools.length === 0) { - onRemoved(); return null; } if (!changed) { From cc04f161a0a07639ac8ce0aa2ad3505dc787a77e Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:11:13 +0800 Subject: [PATCH 363/376] fix-background-response-guardrails --- lib/request/fetch-helpers.ts | 8 ++++- lib/request/request-transformer.ts | 6 ++-- test/fetch-helpers.test.ts | 52 ++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/lib/request/fetch-helpers.ts b/lib/request/fetch-helpers.ts index e3544362..ac0a7efb 100644 --- a/lib/request/fetch-helpers.ts +++ b/lib/request/fetch-helpers.ts @@ -738,7 +738,7 @@ export async function transformRequestForCodex( body: transformedBody as unknown as Record, }); - return { + return { body: transformedBody, updatedInit: { ...(init ?? {}), body: JSON.stringify(transformedBody) }, deferredFastSessionInputTrim: @@ -748,6 +748,12 @@ export async function transformRequestForCodex( : undefined, }; } catch (e) { + if ( + e instanceof Error && + e.message.startsWith("Responses background mode") + ) { + throw e; + } logError(`${ERROR_MESSAGES.REQUEST_PARSE_ERROR}`, e); return undefined; } diff --git a/lib/request/request-transformer.ts b/lib/request/request-transformer.ts index fa2f601e..e47d36ec 100644 --- a/lib/request/request-transformer.ts +++ b/lib/request/request-transformer.ts @@ -978,12 +978,12 @@ export async function transformRequestBody( }) ?? inputItems; body.input = inputItems; - // istanbul ignore next -- filterInput always removes IDs; this is defensive debug code + // istanbul ignore next -- filterInput always removes IDs in stateless mode; this is defensive debug code const remainingIds = (body.input || []) .filter((item) => item.id) .map((item) => item.id); - // istanbul ignore if -- filterInput always removes IDs; defensive debug warning - if (remainingIds.length > 0) { + // istanbul ignore if -- filterInput always removes IDs in stateless mode; background mode intentionally preserves them + if (remainingIds.length > 0 && !backgroundModeRequested) { logWarn( `WARNING: ${remainingIds.length} IDs still present after filtering:`, remainingIds, diff --git a/test/fetch-helpers.test.ts b/test/fetch-helpers.test.ts index 4050ea85..2267b02f 100644 --- a/test/fetch-helpers.test.ts +++ b/test/fetch-helpers.test.ts @@ -1127,6 +1127,58 @@ describe('createEntitlementErrorResponse', () => { expect(typeof result?.updatedInit.body).toBe('string'); }); + it('rethrows background-mode compatibility errors instead of falling back to the raw request', async () => { + const { transformRequestForCodex } = await import('../lib/request/fetch-helpers.js'); + + await expect( + transformRequestForCodex( + { + body: JSON.stringify({ + model: 'gpt-5.4', + background: true, + input: [{ type: 'message', role: 'user', content: 'hello' }], + }), + }, + 'https://example.com', + { global: {}, models: {} }, + ), + ).rejects.toThrow( + 'Responses background mode is disabled. Enable pluginConfig.backgroundResponses or CODEX_AUTH_BACKGROUND_RESPONSES=1 to opt in.', + ); + }); + + it('suppresses deferred fast-session trimming for allowed background requests', async () => { + const { transformRequestForCodex } = await import('../lib/request/fetch-helpers.js'); + + const result = await transformRequestForCodex( + { + body: JSON.stringify({ + model: 'gpt-5.4', + background: true, + input: [ + { id: 'msg_1', type: 'message', role: 'user', content: 'hello' }, + { id: 'msg_2', type: 'message', role: 'assistant', content: 'hi' }, + ], + }), + }, + 'https://example.com', + { global: {}, models: {} }, + true, + undefined, + { + fastSession: true, + fastSessionStrategy: 'always', + fastSessionMaxInputItems: 1, + deferFastSessionInputTrimming: true, + allowBackgroundResponses: true, + }, + ); + + expect(result).toBeDefined(); + expect(result?.body.background).toBe(true); + expect(result?.deferredFastSessionInputTrim).toBeUndefined(); + }); + it('returns undefined when parsedBody is empty object and init body is unavailable', async () => { const { transformRequestForCodex } = await import('../lib/request/fetch-helpers.js'); const result = await transformRequestForCodex( From 5f168285c9de2fc743a2e2b4f4cf08277d478651 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:13:41 +0800 Subject: [PATCH 364/376] Tighten tool-surface regression coverage --- test/request-transformer.test.ts | 37 +++++++++++++++++++++++++++++++- test/tool-utils.test.ts | 8 +++---- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/test/request-transformer.test.ts b/test/request-transformer.test.ts index 83b1053e..bb360838 100644 --- a/test/request-transformer.test.ts +++ b/test/request-transformer.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import { normalizeModel, getModelConfig, @@ -10,11 +10,16 @@ import { addCodexBridgeMessage, transformRequestBody, } from '../lib/request/request-transformer.js'; +import * as loggerModule from '../lib/logger.js'; import { TOOL_REMAP_MESSAGE } from '../lib/prompts/codex.js'; import { CODEX_HOST_BRIDGE } from '../lib/prompts/codex-host-bridge.js'; import type { RequestBody, UserConfig, InputItem } from '../lib/types.js'; describe('Request Transformer Module', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + describe('normalizeModel', () => { it('keeps codex families canonical', async () => { expect(normalizeModel('gpt-5-codex')).toBe('gpt-5-codex'); @@ -2033,6 +2038,36 @@ describe('Request Transformer Module', () => { ]); }); + it('counts only removed plan-only tools when a namespace becomes empty', async () => { + const warnSpy = vi.spyOn(loggerModule, 'logWarn').mockImplementation(() => {}); + const body: RequestBody = { + model: 'gpt-5', + input: [ + { + type: 'message', + role: 'developer', + content: [{ type: 'input_text', text: '# Collaboration Mode: Default' }], + }, + ], + tools: [ + { + type: 'namespace', + name: 'planner', + tools: [ + { type: 'function', function: { name: 'request_user_input', parameters: { type: 'object', properties: {} } } }, + ], + }, + ] as any, + }; + + const result = await transformRequestBody(body, codexInstructions); + + expect(result.tools).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith( + 'Removed 1 plan-mode-only tool definition(s) because collaboration mode is default', + ); + }); + it('removes tool_search tools when the selected model lacks search capability', async () => { const body: RequestBody = { model: 'gpt-5-nano', diff --git a/test/tool-utils.test.ts b/test/tool-utils.test.ts index b150c44e..c7f864fc 100644 --- a/test/tool-utils.test.ts +++ b/test/tool-utils.test.ts @@ -3,10 +3,10 @@ import { cleanupToolDefinitions } from "../lib/request/helpers/tool-utils.js"; import type { RequestToolDefinition } from "../lib/types.js"; describe("cleanupToolDefinitions", () => { - it("returns non-array input unchanged", () => { - expect(cleanupToolDefinitions(null)).toBeNull(); - expect(cleanupToolDefinitions("string")).toBe("string"); - expect(cleanupToolDefinitions({})).toEqual({}); + it("returns undefined for non-array input", () => { + expect(cleanupToolDefinitions(null)).toBeUndefined(); + expect(cleanupToolDefinitions("string" as unknown as RequestToolDefinition[])).toBeUndefined(); + expect(cleanupToolDefinitions({} as unknown as RequestToolDefinition[])).toBeUndefined(); }); it("returns non-function tools unchanged", () => { From 66f5d33536638015c306a5007fcef936c5fdf48a Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:00:02 +0800 Subject: [PATCH 365/376] document and test background responses compatibility --- README.md | 3 +++ docs/development/CONFIG_FIELDS.md | 5 +++++ docs/reference/public-api.md | 5 ++--- docs/upgrade.md | 10 ++++++++++ test/fetch-helpers.test.ts | 24 ++++++++++++++++++++++++ test/plugin-config.test.ts | 1 + test/request-transformer.test.ts | 23 +++++++++++++++++++++++ 7 files changed, 68 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f4e5e25f..73361f23 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,7 @@ Selected runtime/environment overrides: | `CODEX_TUI_V2=0/1` | Disable/enable TUI v2 | | `CODEX_TUI_COLOR_PROFILE=truecolor|ansi256|ansi16` | TUI color profile | | `CODEX_TUI_GLYPHS=ascii|unicode|auto` | TUI glyph style | +| `CODEX_AUTH_BACKGROUND_RESPONSES=0/1` | Opt in/out of stateful Responses `background: true` compatibility | | `CODEX_AUTH_FETCH_TIMEOUT_MS=` | Request timeout override | | `CODEX_AUTH_STREAM_STALL_TIMEOUT_MS=` | Stream stall timeout override | @@ -210,6 +211,8 @@ codex auth check codex auth forecast --live ``` +Responses background mode stays opt-in. Enable `backgroundResponses` in settings or `CODEX_AUTH_BACKGROUND_RESPONSES=1` only for callers that intentionally send `background: true`, because those requests switch from stateless `store=false` routing to stateful `store=true`. See [docs/upgrade.md](docs/upgrade.md) for rollout guidance. + --- ## Experimental Settings Highlights diff --git a/docs/development/CONFIG_FIELDS.md b/docs/development/CONFIG_FIELDS.md index 5349bcc0..f5bf7b88 100644 --- a/docs/development/CONFIG_FIELDS.md +++ b/docs/development/CONFIG_FIELDS.md @@ -83,6 +83,11 @@ Used only for host plugin mode through the host runtime config file. `backgroundResponses` is an opt-in compatibility switch for Responses API `background: true` requests. When enabled, those requests become stateful (`store=true`) instead of following the default stateless Codex routing. +Upgrade note: +- Leave this disabled for existing stateless pipelines that do not intentionally send `background: true`. +- Enable it only for callers that need stateful background responses and can accept forced `store=true`, preserved input item IDs, and the loss of stateless-only defaults such as fast-session trimming. +- After enabling it, test one known `background: true` request end to end before rolling it across shared automation. + ### Storage / Sync | Key | Default | diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md index db0e1b84..15182b3d 100644 --- a/docs/reference/public-api.md +++ b/docs/reference/public-api.md @@ -67,11 +67,10 @@ Positional signatures are preserved for backward compatibility. The request-transform layer intentionally preserves and/or normalizes modern Responses API fields that callers may already send through the host SDK. -- `previous_response_id` is preserved when explicitly provided and may be auto-filled from plugin continuation state when `pluginConfig.responseContinuation` is enabled. +- The plugin preserves `previous_response_id` when explicitly provided and may auto-fill it from plugin continuation state when `pluginConfig.responseContinuation` is enabled, maintains `text.format` when verbosity defaults are applied, and honors `prompt_cache_retention` from the request body before falling back to `providerOptions.openai.promptCacheRetention` or user config defaults. - `background` is typed as a first-class request field. It remains disabled by default and only passes through when `pluginConfig.backgroundResponses` or `CODEX_AUTH_BACKGROUND_RESPONSES=1` explicitly enables the stateful compatibility path. -- `text.format` is preserved when verbosity defaults are applied, so structured-output contracts continue to flow through untouched. -- `prompt_cache_retention` is preserved from the request body and can fall back to `providerOptions.openai.promptCacheRetention` or user config defaults. - Background-mode requests force `store=true`, keep caller-supplied input item IDs, and skip stateless-only defaults such as `reasoning.encrypted_content` injection and fast-session trimming. +- Upgrade note: leave background mode disabled for existing stateless pipelines. Enable it only for callers that intentionally send `background: true` and are ready for stateful `store=true` routing. For rollout steps, see [../upgrade.md](../upgrade.md). - Hosted built-in tool definitions are typed and supported for: - `tool_search` - remote `mcp` diff --git a/docs/upgrade.md b/docs/upgrade.md index 00883363..519f2641 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -76,6 +76,16 @@ For maintainer/debug flows, see advanced/internal controls in [development/CONFI --- +## Responses Background Mode Upgrade Note + +`backgroundResponses` and `CODEX_AUTH_BACKGROUND_RESPONSES=1` are opt-in compatibility controls for callers that intentionally send Responses API `background: true`. + +- Leave them disabled for existing stateless pipelines. The default routing remains `store=false`. +- Enabling them switches background requests onto the stateful path, forces `store=true`, preserves caller-supplied input item IDs, and skips stateless-only defaults such as fast-session trimming and `reasoning.encrypted_content` injection. +- No new npm scripts or storage migrations are required, but you should validate one known `background: true` request end to end before enabling the flag across shared automation. + +--- + ## Onboarding Restore Note `codex auth login` now opens directly into the sign-in menu when the active pool is empty, instead of opening the account dashboard first. diff --git a/test/fetch-helpers.test.ts b/test/fetch-helpers.test.ts index 2267b02f..4f0e724f 100644 --- a/test/fetch-helpers.test.ts +++ b/test/fetch-helpers.test.ts @@ -1179,6 +1179,30 @@ describe('createEntitlementErrorResponse', () => { expect(result?.deferredFastSessionInputTrim).toBeUndefined(); }); + it('rethrows store=false background guardrail errors at the fetch layer', async () => { + const { transformRequestForCodex } = await import('../lib/request/fetch-helpers.js'); + + await expect( + transformRequestForCodex( + { + body: JSON.stringify({ + model: 'gpt-5.4', + background: true, + store: false, + input: [{ type: 'message', role: 'user', content: 'hello' }], + }), + }, + 'https://example.com', + { global: {}, models: {} }, + true, + undefined, + { allowBackgroundResponses: true }, + ), + ).rejects.toThrow( + 'Responses background mode requires store=true and cannot be combined with stateless store=false routing.', + ); + }); + it('returns undefined when parsedBody is empty object and init body is unavailable', async () => { const { transformRequestForCodex } = await import('../lib/request/fetch-helpers.js'); const result = await transformRequestForCodex( diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index e9c2762d..5166ef00 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -66,6 +66,7 @@ describe('Plugin Configuration', () => { 'CODEX_AUTH_FALLBACK_UNSUPPORTED_MODEL', 'CODEX_AUTH_FALLBACK_GPT53_TO_GPT52', 'CODEX_AUTH_RESPONSE_CONTINUATION', + 'CODEX_AUTH_BACKGROUND_RESPONSES', 'CODEX_AUTH_PREEMPTIVE_QUOTA_ENABLED', 'CODEX_AUTH_PREEMPTIVE_QUOTA_5H_REMAINING_PCT', 'CODEX_AUTH_PREEMPTIVE_QUOTA_7D_REMAINING_PCT', diff --git a/test/request-transformer.test.ts b/test/request-transformer.test.ts index 99b09ad1..670633d6 100644 --- a/test/request-transformer.test.ts +++ b/test/request-transformer.test.ts @@ -2537,6 +2537,29 @@ describe('Request Transformer Module', () => { ); }); + it('rejects background mode when providerOptions still forces store=false', async () => { + await expect( + transformRequestBody( + { + model: 'gpt-5.4', + background: true, + providerOptions: { openai: { store: false } }, + input: [{ type: 'message', role: 'user', content: 'hello' }], + }, + codexInstructions, + { global: {}, models: {} }, + true, + false, + 'hybrid', + 30, + false, + true, + ), + ).rejects.toThrowError( + 'Responses background mode requires store=true and cannot be combined with stateless store=false routing.', + ); + }); + it('throws clear TypeError when named-parameter body is invalid', async () => { await expect( transformRequestBody({ From 31fdf39aaa4be1e597e8f4ff1272a328d5d68a29 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 21:33:29 +0800 Subject: [PATCH 366/376] test: cover auth command error paths --- test/codex-manager-auth-commands.test.ts | 239 ++++++++++++++++++++++- 1 file changed, 238 insertions(+), 1 deletion(-) diff --git a/test/codex-manager-auth-commands.test.ts b/test/codex-manager-auth-commands.test.ts index 05d170de..03154783 100644 --- a/test/codex-manager-auth-commands.test.ts +++ b/test/codex-manager-auth-commands.test.ts @@ -4,7 +4,12 @@ import type { DashboardDisplaySettings, } from "../lib/dashboard-settings.js"; import type { QuotaCacheData } from "../lib/quota-cache.js"; -import type { AccountStorageV3, NamedBackupSummary } from "../lib/storage.js"; +import { + formatStorageErrorHint, + StorageError, + type AccountStorageV3, + type NamedBackupSummary, +} from "../lib/storage.js"; import type { TokenResult } from "../lib/types.js"; const { @@ -23,12 +28,16 @@ const { loadFlaggedAccountsMock, saveAccountsMock, setStoragePathMock, + getStoragePathMock, getNamedBackupsMock, restoreAccountsFromBackupMock, setCodexCliActiveSelectionMock, confirmMock, applyUiThemeFromDashboardSettingsMock, configureUnifiedSettingsMock, + leaseReleaseMock, + leaseAcquireMock, + refreshLeaseCoordinatorFromEnvironmentMock, } = vi.hoisted(() => ({ extractAccountEmailMock: vi.fn(), extractAccountIdMock: vi.fn(), @@ -45,12 +54,21 @@ const { loadFlaggedAccountsMock: vi.fn(), saveAccountsMock: vi.fn(), setStoragePathMock: vi.fn(), + getStoragePathMock: vi.fn(), getNamedBackupsMock: vi.fn(), restoreAccountsFromBackupMock: vi.fn(), setCodexCliActiveSelectionMock: vi.fn(), confirmMock: vi.fn(), applyUiThemeFromDashboardSettingsMock: vi.fn(), configureUnifiedSettingsMock: vi.fn(), + leaseReleaseMock: vi.fn(async () => undefined), + leaseAcquireMock: vi.fn(async () => ({ + role: "bypass", + release: leaseReleaseMock, + })), + refreshLeaseCoordinatorFromEnvironmentMock: vi.fn(() => ({ + acquire: leaseAcquireMock, + })), })); vi.mock("../lib/auth/browser.js", () => ({ @@ -94,11 +112,18 @@ vi.mock("../lib/storage.js", async () => { loadFlaggedAccounts: loadFlaggedAccountsMock, saveAccounts: saveAccountsMock, setStoragePath: setStoragePathMock, + getStoragePath: getStoragePathMock, getNamedBackups: getNamedBackupsMock, restoreAccountsFromBackup: restoreAccountsFromBackupMock, }; }); +vi.mock("../lib/refresh-lease.js", () => ({ + RefreshLeaseCoordinator: { + fromEnvironment: refreshLeaseCoordinatorFromEnvironmentMock, + }, +})); + vi.mock("../lib/codex-cli/writer.js", () => ({ setCodexCliActiveSelection: setCodexCliActiveSelectionMock, })); @@ -246,7 +271,18 @@ beforeEach(() => { getNamedBackupsMock.mockResolvedValue([]); restoreAccountsFromBackupMock.mockResolvedValue(createStorage()); saveAccountsMock.mockResolvedValue(undefined); + getStoragePathMock.mockReturnValue( + "/mock/.codex/multi-auth/openai-codex-accounts.json", + ); setCodexCliActiveSelectionMock.mockResolvedValue(true); + leaseReleaseMock.mockResolvedValue(undefined); + leaseAcquireMock.mockImplementation(async () => ({ + role: "bypass", + release: leaseReleaseMock, + })); + refreshLeaseCoordinatorFromEnvironmentMock.mockReturnValue({ + acquire: leaseAcquireMock, + }); queuedRefreshMock.mockResolvedValue({ type: "success", access: "fresh-access-token", @@ -420,6 +456,27 @@ describe("codex-manager auth command helpers", () => { ); }); + it("propagates saveAccounts EBUSY errors before syncing the selected account", async () => { + const busyError = Object.assign( + new Error("EBUSY: resource busy or locked"), + { code: "EBUSY" }, + ); + saveAccountsMock.mockRejectedValueOnce(busyError); + const storage = createStorage(); + + await expect( + persistAndSyncSelectedAccount({ + storage, + targetIndex: 0, + parsed: 1, + switchReason: "rotation", + helpers: createHelpers(), + }), + ).rejects.toBe(busyError); + + expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); + }); + it("validates switch indices before mutating storage", async () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); loadAccountsMock.mockResolvedValue(createStorage()); @@ -452,6 +509,19 @@ describe("codex-manager auth command helpers", () => { expect(output.accountIndex).toBe(1); }); + it("prints best usage and exits 0 on --help", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + + const result = await runBest(["--help"], createHelpers()); + + expect(result).toBe(0); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("codex auth best"), + ); + expect(loadAccountsMock).not.toHaveBeenCalled(); + expect(leaseAcquireMock).not.toHaveBeenCalled(); + }); + it("reports json output when runBest switches to a healthier account", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); setCodexCliActiveSelectionMock.mockResolvedValue(false); @@ -605,6 +675,36 @@ describe("codex-manager auth command helpers", () => { ); }); + it("propagates live best save failures before syncing Codex auth", async () => { + const helpers = createHelpers({ + hasUsableAccessToken: vi.fn(() => false), + }); + const busyError = Object.assign( + new Error("EBUSY: resource busy or locked"), + { code: "EBUSY" }, + ); + loadAccountsMock.mockResolvedValue( + createStorage([ + { + email: "live@example.com", + refreshToken: "refresh-token-live", + accessToken: "stale-access-token", + accountId: "acct-live", + expiresAt: Date.now() - 1, + addedAt: 1, + lastUsed: 1, + enabled: true, + }, + ]), + ); + saveAccountsMock.mockRejectedValueOnce(busyError); + + await expect(runBest(["--live"], helpers)).rejects.toBe(busyError); + + expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); + expect(leaseAcquireMock).toHaveBeenCalledTimes(1); + }); + it("restores a backup through the extracted login flow and clamps family indices", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); const restoredStorage = createStorage([ @@ -685,6 +785,143 @@ describe("codex-manager auth command helpers", () => { expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("latest.json")); }); + it.each([ + { + code: "EBUSY", + message: "File is busy", + hint: "File is locked", + }, + { + code: "EACCES", + message: "Access denied", + hint: "Permission denied", + }, + ])( + "prints the storage hint for $code restore failures and stays in onboarding", + async ({ code, message, hint }) => { + const backupPath = "/mock/backups/locked.json"; + const storageError = new StorageError(message, code, backupPath, hint); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + loadAccountsMock + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + getNamedBackupsMock.mockResolvedValue([ + { + path: backupPath, + fileName: "locked.json", + accountCount: 1, + mtimeMs: Date.now(), + }, + ]); + restoreAccountsFromBackupMock.mockRejectedValueOnce(storageError); + const deps = createAuthLoginDeps({ + promptOAuthSignInMode: vi.fn() + .mockResolvedValueOnce("restore-backup" as const) + .mockResolvedValueOnce("cancel" as const), + promptBackupRestoreMode: vi.fn(async () => "latest" as const), + }); + + const result = await runAuthLogin([], deps); + + expect(result).toBe(0); + expect(confirmMock).toHaveBeenCalledTimes(1); + expect(restoreAccountsFromBackupMock).toHaveBeenCalledWith( + backupPath, + { persist: false }, + ); + expect(saveAccountsMock).not.toHaveBeenCalled(); + expect(promptLoginModeMock).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + formatStorageErrorHint(storageError, backupPath), + ); + }, + ); + + it("reports generic backup restore failures and stays in onboarding when storage is still empty", async () => { + const backupPath = "/mock/backups/rate-limited.json"; + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const rateLimitError = new Error("429 Too Many Requests"); + loadAccountsMock + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + getNamedBackupsMock.mockResolvedValue([ + { + path: backupPath, + fileName: "rate-limited.json", + accountCount: 1, + mtimeMs: Date.now(), + }, + ]); + restoreAccountsFromBackupMock.mockRejectedValueOnce(rateLimitError); + const deps = createAuthLoginDeps({ + promptOAuthSignInMode: vi.fn() + .mockResolvedValueOnce("restore-backup" as const) + .mockResolvedValueOnce("cancel" as const), + promptBackupRestoreMode: vi.fn(async () => "latest" as const), + }); + + const result = await runAuthLogin([], deps); + + expect(result).toBe(0); + expect(confirmMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).not.toHaveBeenCalled(); + expect(promptLoginModeMock).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + "Backup restore failed: 429 Too Many Requests", + ); + }); + + it("returns to the existing-account menu when restore fails after accounts already exist", async () => { + const backupPath = "/mock/backups/existing.json"; + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const existingStorage = createStorage([ + { + email: "existing@example.com", + refreshToken: "refresh-token-existing", + accessToken: "access-token-existing", + accountId: "acct-existing", + expiresAt: Date.now() + 60_000, + addedAt: 1, + lastUsed: 1, + enabled: true, + }, + ]); + loadAccountsMock + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(existingStorage) + .mockResolvedValueOnce(existingStorage) + .mockResolvedValueOnce(existingStorage); + getNamedBackupsMock.mockResolvedValue([ + { + path: backupPath, + fileName: "existing.json", + accountCount: 1, + mtimeMs: Date.now(), + }, + ]); + restoreAccountsFromBackupMock.mockRejectedValueOnce( + new Error("save selected account failed"), + ); + promptLoginModeMock.mockResolvedValue({ mode: "cancel" }); + const deps = createAuthLoginDeps({ + promptOAuthSignInMode: vi.fn(async () => "restore-backup" as const), + promptBackupRestoreMode: vi.fn(async () => "latest" as const), + }); + + const result = await runAuthLogin([], deps); + + expect(result).toBe(0); + expect(confirmMock).toHaveBeenCalledTimes(1); + expect(promptLoginModeMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + "Backup restore failed: save selected account failed", + ); + }); + it("prints usage from runAuthLogin without entering the interactive flow", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); const deps = createAuthLoginDeps(); From 6a273fe4cd5db1636386e06c0a963d1d8a95ee93 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 21:43:00 +0800 Subject: [PATCH 367/376] fix session-affinity response id compatibility --- lib/session-affinity.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/session-affinity.ts b/lib/session-affinity.ts index 9a90950f..1ce27e30 100644 --- a/lib/session-affinity.ts +++ b/lib/session-affinity.ts @@ -98,6 +98,14 @@ export class SessionAffinityStore { * This method does not create a new affinity entry; callers that need to * upsert continuation state should use `rememberWithResponseId`. */ + rememberLastResponseId( + sessionKey: string | null | undefined, + responseId: string | null | undefined, + now = Date.now(), + ): void { + this.updateLastResponseId(sessionKey, responseId, now); + } + updateLastResponseId( sessionKey: string | null | undefined, responseId: string | null | undefined, From b7fef3ec89c718c2e41f415c32ed484e4109708b Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 21:43:00 +0800 Subject: [PATCH 368/376] fix session-affinity response id compatibility --- lib/session-affinity.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/session-affinity.ts b/lib/session-affinity.ts index 9a90950f..1ce27e30 100644 --- a/lib/session-affinity.ts +++ b/lib/session-affinity.ts @@ -98,6 +98,14 @@ export class SessionAffinityStore { * This method does not create a new affinity entry; callers that need to * upsert continuation state should use `rememberWithResponseId`. */ + rememberLastResponseId( + sessionKey: string | null | undefined, + responseId: string | null | undefined, + now = Date.now(), + ): void { + this.updateLastResponseId(sessionKey, responseId, now); + } + updateLastResponseId( sessionKey: string | null | undefined, responseId: string | null | undefined, From 79b39f45ef131fa882eb54a21a5f90b0331f56ce Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 21:52:47 +0800 Subject: [PATCH 369/376] test: cover live lease failures --- test/codex-manager-auth-commands.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/codex-manager-auth-commands.test.ts b/test/codex-manager-auth-commands.test.ts index 03154783..fd8ae2f6 100644 --- a/test/codex-manager-auth-commands.test.ts +++ b/test/codex-manager-auth-commands.test.ts @@ -522,6 +522,21 @@ describe("codex-manager auth command helpers", () => { expect(leaseAcquireMock).not.toHaveBeenCalled(); }); + it("propagates live best lease acquisition failures before loading accounts", async () => { + const leaseError = Object.assign( + new Error("EACCES: permission denied"), + { code: "EACCES" }, + ); + loadAccountsMock.mockResolvedValue(createStorage()); + leaseAcquireMock.mockRejectedValueOnce(leaseError); + + await expect(runBest(["--live"], createHelpers())).rejects.toBe(leaseError); + + expect(loadAccountsMock).not.toHaveBeenCalled(); + expect(saveAccountsMock).not.toHaveBeenCalled(); + expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled(); + }); + it("reports json output when runBest switches to a healthier account", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); setCodexCliActiveSelectionMock.mockResolvedValue(false); From 273e46a8f4d8224fa14c8e6227f5468360168147 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 22:44:48 +0800 Subject: [PATCH 370/376] fix: drop unused storage identity import --- lib/storage.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/storage.ts b/lib/storage.ts index 8ecd8f69..17f1ec9f 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -24,7 +24,6 @@ export { import { type AccountIdentityRef, - normalizeEmailKey, toAccountIdentityRef, } from "./storage/identity.js"; import { From f7cb0a82a984e7e37d670cb833cd0a209118c22d Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 22:44:48 +0800 Subject: [PATCH 371/376] fix: preserve vendor provenance notes --- scripts/update-vendor-provenance.mjs | 39 +++++++++++++++++++++++++--- vendor/provenance.json | 2 +- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/scripts/update-vendor-provenance.mjs b/scripts/update-vendor-provenance.mjs index 52ddd534..948603c0 100644 --- a/scripts/update-vendor-provenance.mjs +++ b/scripts/update-vendor-provenance.mjs @@ -27,11 +27,37 @@ const components = [ }, ]; +async function loadExistingManifest() { + try { + const content = await readFile("vendor/provenance.json", "utf8"); + const parsed = JSON.parse(content); + return Array.isArray(parsed?.components) ? parsed : { components: [] }; + } catch { + return { components: [] }; + } +} + async function hashFile(path) { const content = await readFile(path); return createHash("sha256").update(content).digest("hex"); } +const existingManifest = await loadExistingManifest(); +const existingFiles = Object.fromEntries( + existingManifest.components.flatMap((component) => + Array.isArray(component.files) + ? component.files + .filter( + (file) => + file && + typeof file === "object" && + typeof file.path === "string", + ) + .map((file) => [file.path, file]) + : [], + ), +); + const manifest = { generatedAt: new Date().toISOString().slice(0, 10), components: [], @@ -47,10 +73,15 @@ for (const component of components) { source: component.source, root: component.root, files: await Promise.all( - component.files.map(async (path) => ({ - path, - sha256: await hashFile(path), - })), + component.files.map(async (path) => { + const prior = existingFiles[path] ?? {}; + const { path: _priorPath, sha256: _priorHash, ...extra } = prior; + return { + path, + sha256: await hashFile(path), + ...extra, + }; + }), ), }); } diff --git a/vendor/provenance.json b/vendor/provenance.json index 0b81ad66..64dffbc3 100644 --- a/vendor/provenance.json +++ b/vendor/provenance.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-03-21", + "generatedAt": "2026-03-22", "components": [ { "name": "@codex-ai/plugin", From c6de32c147434b9574b1318edf2742dbbedbf7d0 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 23:02:44 +0800 Subject: [PATCH 372/376] test: cover statusline panel hotkeys --- test/settings-hub-utils.test.ts | 56 +++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/test/settings-hub-utils.test.ts b/test/settings-hub-utils.test.ts index 6e0cc6de..36ca1e3b 100644 --- a/test/settings-hub-utils.test.ts +++ b/test/settings-hub-utils.test.ts @@ -644,6 +644,62 @@ describe("settings-hub utility coverage", () => { ]); }); + it("returns null for statusline settings when stdin or stdout is not a tty", async () => { + const api = await loadSettingsHubTestApi(); + setStreamIsTTY(process.stdin, false); + setStreamIsTTY(process.stdout, false); + await expect( + api.promptStatuslineSettings({ + ...DEFAULT_DASHBOARD_DISPLAY_SETTINGS, + }), + ).resolves.toBeNull(); + }); + + it("supports statusline reorder hotkeys before saving", async () => { + const api = await loadSettingsHubTestApi(); + queueSelectResults( + triggerSettingsHubHotkey("]"), + triggerSettingsHubHotkey("s"), + ); + const selected = await api.promptStatuslineSettings({ + ...DEFAULT_DASHBOARD_DISPLAY_SETTINGS, + menuStatuslineFields: ["last-used", "limits", "status"], + }); + expect(selected?.menuStatuslineFields).toEqual([ + "limits", + "last-used", + "status", + ]); + }); + + it("supports statusline reset hotkeys before saving", async () => { + const api = await loadSettingsHubTestApi(); + queueSelectResults( + triggerSettingsHubHotkey("r"), + triggerSettingsHubHotkey("s"), + ); + const selected = await api.promptStatuslineSettings({ + ...DEFAULT_DASHBOARD_DISPLAY_SETTINGS, + menuStatuslineFields: ["status"], + }); + expect(selected?.menuStatuslineFields).toEqual( + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuStatuslineFields, + ); + }); + + it("keeps the last statusline field enabled when toggled off by numeric hotkey", async () => { + const api = await loadSettingsHubTestApi(); + queueSelectResults( + triggerSettingsHubHotkey("1"), + triggerSettingsHubHotkey("s"), + ); + const selected = await api.promptStatuslineSettings({ + ...DEFAULT_DASHBOARD_DISPLAY_SETTINGS, + menuStatuslineFields: ["last-used"], + }); + expect(selected?.menuStatuslineFields).toEqual(["last-used"]); + }); + it("toggles behavior settings before returning the draft", async () => { const api = await loadSettingsHubTestApi(); queueSelectResults({ type: "toggle-pause" }, { type: "save" }); From 037f1ea5c40ae6b01f52cbb9945fedb74ee7a8c8 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Mon, 23 Mar 2026 01:32:00 +0800 Subject: [PATCH 373/376] fix-release-validation-regressions --- lib/codex-manager/backend-settings-schema.ts | 2 +- lib/codex-manager/commands/forecast.ts | 9 +-- lib/codex-manager/settings-write-queue.ts | 1 + lib/storage/backup-metadata-builder.ts | 67 +++++++++++++++++++- test/backup-metadata-builder.test.ts | 49 ++++++++++++++ test/documentation.test.ts | 10 ++- test/storage.test.ts | 56 ++++++++++++++++ 7 files changed, 185 insertions(+), 9 deletions(-) diff --git a/lib/codex-manager/backend-settings-schema.ts b/lib/codex-manager/backend-settings-schema.ts index 1995b913..6c4c8131 100644 --- a/lib/codex-manager/backend-settings-schema.ts +++ b/lib/codex-manager/backend-settings-schema.ts @@ -170,7 +170,7 @@ export const BACKEND_NUMBER_OPTIONS: BackendNumberSettingOption[] = [ label: "Session Affinity Max Entries", description: "Upper bound for tracked affinity sessions.", min: 8, - max: 10_000, + max: 4_096, step: 8, unit: "count", }, diff --git a/lib/codex-manager/commands/forecast.ts b/lib/codex-manager/commands/forecast.ts index 6c0e277f..8c2f831d 100644 --- a/lib/codex-manager/commands/forecast.ts +++ b/lib/codex-manager/commands/forecast.ts @@ -29,7 +29,7 @@ type QuotaEmailFallbackState = ReadonlyMap< export interface ForecastCommandDeps { setStoragePath: (path: string | null) => void; loadAccounts: () => Promise; - loadDashboardDisplaySettings: () => Promise; + loadDashboardDisplaySettings?: () => Promise; resolveActiveIndex: (storage: AccountStorageV3, family?: "codex") => number; loadQuotaCache: () => Promise; saveQuotaCache: (cache: QuotaCacheData) => Promise; @@ -232,9 +232,10 @@ export async function runForecastCommand( const options = parsedArgs.options; const requestedModel = options.model?.trim() || "gpt-5-codex"; const probeModel = resolveNormalizedModel(requestedModel); - const display = - (await deps.loadDashboardDisplaySettings().catch(() => null)) ?? - deps.defaultDisplay; + const display = deps.loadDashboardDisplaySettings + ? (await deps.loadDashboardDisplaySettings().catch(() => null)) ?? + deps.defaultDisplay + : deps.defaultDisplay; const quotaCache = options.live ? await deps.loadQuotaCache() : null; const workingQuotaCache = quotaCache ? deps.cloneQuotaCacheData(quotaCache) diff --git a/lib/codex-manager/settings-write-queue.ts b/lib/codex-manager/settings-write-queue.ts index 791dc5c2..dc89fc08 100644 --- a/lib/codex-manager/settings-write-queue.ts +++ b/lib/codex-manager/settings-write-queue.ts @@ -6,6 +6,7 @@ export const RETRYABLE_SETTINGS_WRITE_CODES = new Set([ "EPERM", "EAGAIN", "ENOTEMPTY", + "EACCES", ]); const settingsWriteQueues = new Map>(); diff --git a/lib/storage/backup-metadata-builder.ts b/lib/storage/backup-metadata-builder.ts index f61efbf7..2fe27ba0 100644 --- a/lib/storage/backup-metadata-builder.ts +++ b/lib/storage/backup-metadata-builder.ts @@ -23,6 +23,61 @@ type Snapshot = { schemaErrors?: string[]; }; +const ACCOUNT_BACKUP_PREFERRED_KINDS = new Set([ + "accounts-discovered-backup", + "accounts-backup-history", + "accounts-backup", +]); + +const ACCOUNT_SNAPSHOT_PRIORITY = new Map([ + ["accounts-discovered-backup", 4], + ["accounts-backup-history", 3], + ["accounts-backup", 2], + ["accounts-wal", 1], + ["accounts-primary", 0], +]); + +function newestValidSnapshot( + snapshots: Snapshot[], + options?: { + kinds?: ReadonlySet; + priorities?: ReadonlyMap; + }, +): Snapshot | undefined { + return snapshots + .filter( + (snapshot) => + snapshot.valid && + (!options?.kinds || options.kinds.has(snapshot.kind)), + ) + .sort((left, right) => { + const rightPriority = options?.priorities?.get(right.kind) ?? 0; + const leftPriority = options?.priorities?.get(left.kind) ?? 0; + if (rightPriority !== leftPriority) { + return rightPriority - leftPriority; + } + return (right.mtimeMs ?? 0) - (left.mtimeMs ?? 0); + })[0]; +} + +function selectLatestValidAccountPath( + snapshots: Snapshot[], +): string | undefined { + return ( + newestValidSnapshot(snapshots, { + kinds: ACCOUNT_BACKUP_PREFERRED_KINDS, + priorities: ACCOUNT_SNAPSHOT_PRIORITY, + })?.path ?? + newestValidSnapshot(snapshots, { + kinds: new Set(["accounts-wal"]), + priorities: ACCOUNT_SNAPSHOT_PRIORITY, + })?.path ?? + newestValidSnapshot(snapshots, { + priorities: ACCOUNT_SNAPSHOT_PRIORITY, + })?.path + ); +} + export async function buildBackupMetadata(params: { storagePath: string; flaggedPath: string; @@ -106,8 +161,16 @@ export async function buildBackupMetadata(params: { ); } + const accountsMetadata = buildMetadataSection(storagePath, accountSnapshots); + const flaggedMetadata = buildMetadataSection(flaggedPath, flaggedSnapshots); + return { - accounts: buildMetadataSection(storagePath, accountSnapshots), - flaggedAccounts: buildMetadataSection(flaggedPath, flaggedSnapshots), + accounts: { + ...accountsMetadata, + latestValidPath: + selectLatestValidAccountPath(accountSnapshots) ?? + accountsMetadata.latestValidPath, + }, + flaggedAccounts: flaggedMetadata, }; } diff --git a/test/backup-metadata-builder.test.ts b/test/backup-metadata-builder.test.ts index 1628a67f..a646784f 100644 --- a/test/backup-metadata-builder.test.ts +++ b/test/backup-metadata-builder.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { buildBackupMetadata } from "../lib/storage/backup-metadata-builder.js"; +import { buildMetadataSection } from "../lib/storage/metadata-section.js"; describe("backup metadata builder", () => { it("builds account and flagged metadata sections from discovered snapshots", async () => { @@ -48,4 +49,52 @@ describe("backup metadata builder", () => { expect(result.accounts.snapshotCount).toBe(3); expect(result.flaggedAccounts.snapshotCount).toBe(2); }); + + it("prefers discovered account backups over WAL when selecting latestValidPath", async () => { + const storagePath = "/tmp/accounts.json"; + const walPath = `${storagePath}.wal`; + const backupPath = `${storagePath}.bak`; + const manualPath = `${storagePath}.manual-checkpoint`; + + const result = await buildBackupMetadata({ + storagePath, + flaggedPath: "/tmp/flagged.json", + walPath, + getAccountsBackupRecoveryCandidatesWithDiscovery: async (path) => + path.includes("flagged") ? [] : [backupPath, manualPath], + describeAccountSnapshot: async (path, kind, index) => ({ + path, + kind, + index, + exists: true, + valid: kind !== "accounts-primary", + mtimeMs: + path === manualPath + ? 100 + : path === backupPath + ? 100 + : path === walPath + ? 200 + : 50, + }), + describeAccountsWalSnapshot: async (path) => ({ + path, + kind: "accounts-wal", + exists: true, + valid: true, + mtimeMs: 200, + }), + describeFlaggedSnapshot: async (path, kind, index) => ({ + path, + kind, + index, + exists: true, + valid: true, + mtimeMs: 10, + }), + buildMetadataSection, + }); + + expect(result.accounts.latestValidPath).toBe(manualPath); + }); }); diff --git a/test/documentation.test.ts b/test/documentation.test.ts index 344de523..c6c4408a 100644 --- a/test/documentation.test.ts +++ b/test/documentation.test.ts @@ -256,11 +256,17 @@ describe("Documentation Integrity", () => { const readme = read("README.md"); const commandRef = read("docs/reference/commands.md"); const managerPath = "lib/codex-manager.ts"; + const switchPath = "lib/codex-manager/commands/switch.ts"; expect( existsSync(join(projectRoot, managerPath)), `${managerPath} should exist`, ).toBe(true); + expect( + existsSync(join(projectRoot, switchPath)), + `${switchPath} should exist`, + ).toBe(true); const manager = read(managerPath); + const switchCommand = read(switchPath); expect(readme).toContain("codex auth fix --live --model gpt-5-codex"); expect(commandRef).toContain( @@ -274,10 +280,10 @@ describe("Documentation Integrity", () => { expect(manager).toContain( "codex auth fix [--dry-run] [--json] [--live] [--model ]", ); - expect(manager).toContain( + expect(switchCommand).toContain( "Missing index. Usage: codex auth switch ", ); - expect(manager).not.toContain("codex-multi-auth auth switch "); + expect(switchCommand).not.toContain("codex-multi-auth auth switch "); }); it("keeps maintainer runbooks present", () => { diff --git a/test/storage.test.ts b/test/storage.test.ts index 32480c74..7776a517 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -987,6 +987,62 @@ describe("storage", () => { ); }); + it("ignores stale transaction snapshots from a different storage path during export", async () => { + const populatedStoragePath = join( + testWorkDir, + "accounts-populated.json", + ); + setStoragePathDirect(populatedStoragePath); + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "populated", + refreshToken: "ref-populated", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const actualTransactions = await vi.importActual< + typeof import("../lib/storage/transactions.js") + >("../lib/storage/transactions.js"); + vi.resetModules(); + vi.doMock("../lib/storage/transactions.js", () => ({ + ...actualTransactions, + getTransactionSnapshotState: () => ({ + active: true, + storagePath: populatedStoragePath, + snapshot: { + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "stale", + refreshToken: "stale-refresh", + addedAt: 1, + lastUsed: 1, + }, + ], + }, + }), + })); + + try { + const isolatedStorageModule = await import("../lib/storage.js"); + isolatedStorageModule.setStoragePathDirect(testStoragePath); + await expect( + isolatedStorageModule.exportAccounts(exportPath), + ).rejects.toThrow(/No accounts to export/); + } finally { + vi.doUnmock("../lib/storage/transactions.js"); + vi.resetModules(); + setStoragePathDirect(testStoragePath); + } + }); + it("should fail import when file does not exist", async () => { const { importAccounts } = await import("../lib/storage.js"); const nonexistentPath = join(testWorkDir, "nonexistent-file.json"); From 4f624c74be1e798f6e4a7073d4a7a5471fb42b2a Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Mon, 23 Mar 2026 04:49:05 +0800 Subject: [PATCH 374/376] test: harden merged refactor foundations --- index.ts | 9 +--- lib/codex-manager/settings-hub.ts | 7 ++- lib/storage/import-export.ts | 46 ++++++++++++++-- test/backend-settings-prompt.test.ts | 79 ++++++++++++++++++++++++++-- test/import-export.test.ts | 40 ++++++++++++++ test/runtime-services.test.ts | 38 +++++++++++++ 6 files changed, 201 insertions(+), 18 deletions(-) diff --git a/index.ts b/index.ts index da3336bd..2cb59795 100644 --- a/index.ts +++ b/index.ts @@ -42,7 +42,6 @@ import { sanitizeEmail, selectBestAccountCandidate, shouldUpdateAccountIdFromToken, - type Workspace, } from "./lib/accounts.js"; import { createAuthorizationFlow, @@ -256,7 +255,6 @@ import { createHashlineReadTool, } from "./lib/tools/hashline-tools.js"; import type { - AccountIdSource, OAuthAuthDetails, RequestBody, TokenResult, @@ -369,12 +367,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }; type TokenSuccess = Extract; - type TokenSuccessWithAccount = AccountPoolTokenSuccessWithAccount & { - accountIdOverride?: string; - accountIdSource?: AccountIdSource; - accountLabel?: string; - workspaces?: Workspace[]; - }; + type TokenSuccessWithAccount = AccountPoolTokenSuccessWithAccount; const resolveTokenSuccessAccount = ( tokens: TokenSuccess, diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 1a53fa1f..5dd0dd7b 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -605,9 +605,14 @@ async function promptBackendCategorySettings( async function promptBackendSettings( initial: PluginConfig, ): Promise { + const interactive = input.isTTY && output.isTTY; + if (!interactive) { + return null; + } + return promptBackendSettingsMenu({ initial, - isInteractive: () => input.isTTY && output.isTTY, + isInteractive: () => interactive, ui: getUiRuntimeOptions(), cloneBackendPluginConfig, backendCategoryOptions: BACKEND_CATEGORY_OPTIONS, diff --git a/lib/storage/import-export.ts b/lib/storage/import-export.ts index 32d2827f..9f119868 100644 --- a/lib/storage/import-export.ts +++ b/lib/storage/import-export.ts @@ -2,6 +2,32 @@ import { existsSync, promises as fs } from "node:fs"; import { dirname } from "node:path"; import type { AccountStorageV3 } from "../storage.js"; +const EXPORT_RENAME_MAX_ATTEMPTS = 4; +const EXPORT_RENAME_BASE_DELAY_MS = 25; + +async function renameExportFileWithRetry( + sourcePath: string, + destinationPath: string, +): Promise { + for (let attempt = 0; attempt < EXPORT_RENAME_MAX_ATTEMPTS; attempt += 1) { + try { + await fs.rename(sourcePath, destinationPath); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + const canRetry = + (code === "EPERM" || code === "EBUSY" || code === "EAGAIN") && + attempt + 1 < EXPORT_RENAME_MAX_ATTEMPTS; + if (!canRetry) { + throw error; + } + await new Promise((resolve) => + setTimeout(resolve, EXPORT_RENAME_BASE_DELAY_MS * 2 ** attempt), + ); + } + } +} + export async function exportAccountsToFile(params: { resolvedPath: string; force: boolean; @@ -32,10 +58,22 @@ export async function exportAccountsToFile(params: { null, 2, ); - await fs.writeFile(params.resolvedPath, content, { - encoding: "utf-8", - mode: 0o600, - }); + const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; + const tempPath = `${params.resolvedPath}.${uniqueSuffix}.tmp`; + try { + await fs.writeFile(tempPath, content, { + encoding: "utf-8", + mode: 0o600, + }); + await renameExportFileWithRetry(tempPath, params.resolvedPath); + } catch (error) { + try { + await fs.unlink(tempPath); + } catch { + // Ignore cleanup failures for staged export files. + } + throw error; + } params.logInfo("Exported accounts", { path: params.resolvedPath, count: params.storage.accounts.length, diff --git a/test/backend-settings-prompt.test.ts b/test/backend-settings-prompt.test.ts index 380811fe..1b36171a 100644 --- a/test/backend-settings-prompt.test.ts +++ b/test/backend-settings-prompt.test.ts @@ -1,12 +1,27 @@ import { describe, expect, it, vi } from "vitest"; +import type { BackendCategoryOption } from "../lib/codex-manager/backend-settings-schema.js"; import { promptBackendSettingsMenu } from "../lib/codex-manager/backend-settings-prompt.js"; +import { getUiRuntimeOptions, resetUiRuntimeOptions } from "../lib/ui/runtime.js"; + +function createUiRuntimeOptions() { + resetUiRuntimeOptions(); + return getUiRuntimeOptions(); +} + +const sessionSyncCategory = { + key: "session-sync", + label: "Session Sync", + description: "desc", + toggleKeys: [], + numberKeys: [], +} satisfies BackendCategoryOption; describe("backend settings prompt", () => { it("returns null when not interactive", async () => { const result = await promptBackendSettingsMenu({ initial: { fetchTimeoutMs: 1000 }, isInteractive: () => false, - ui: { theme: {} } as never, + ui: createUiRuntimeOptions(), cloneBackendPluginConfig: (config) => ({ ...config }), backendCategoryOptions: [], getBackendCategoryInitialFocus: vi.fn(), @@ -40,11 +55,9 @@ describe("backend settings prompt", () => { const result = await promptBackendSettingsMenu({ initial: { fetchTimeoutMs: 5000 }, isInteractive: () => true, - ui: { theme: {} } as never, + ui: createUiRuntimeOptions(), cloneBackendPluginConfig: (config) => ({ ...config }), - backendCategoryOptions: [ - { key: "session-sync", label: "Session Sync", description: "desc" }, - ] as never, + backendCategoryOptions: [sessionSyncCategory], getBackendCategoryInitialFocus: () => null, buildBackendSettingsPreview: () => ({ label: "Preview", hint: "Hint" }), highlightPreviewToken: vi.fn((text) => text), @@ -66,4 +79,60 @@ describe("backend settings prompt", () => { expect(result).toEqual({ fetchTimeoutMs: 1000 }); }); + + it("persists category drill-down draft updates and focus before save", async () => { + const ui = createUiRuntimeOptions(); + const buildBackendSettingsPreview = vi + .fn() + .mockReturnValue({ label: "Preview", hint: "Hint" }); + const promptBackendCategorySettings = vi.fn().mockResolvedValue({ + draft: { fetchTimeoutMs: 2500 }, + focusKey: "fetchTimeoutMs", + }); + const select = vi + .fn() + .mockResolvedValueOnce({ type: "open-category", key: "session-sync" }) + .mockResolvedValueOnce({ type: "save" }); + + const result = await promptBackendSettingsMenu({ + initial: { fetchTimeoutMs: 5000 }, + isInteractive: () => true, + ui, + cloneBackendPluginConfig: (config) => ({ ...config }), + backendCategoryOptions: [sessionSyncCategory], + getBackendCategoryInitialFocus: () => null, + buildBackendSettingsPreview, + highlightPreviewToken: vi.fn((text) => text), + select, + getBackendCategory: vi.fn((key) => + key === sessionSyncCategory.key ? sessionSyncCategory : null, + ), + promptBackendCategorySettings, + backendDefaults: { fetchTimeoutMs: 1000 }, + copy: { + previewHeading: "Preview", + backendCategoriesHeading: "Categories", + resetDefault: "Reset", + saveAndBack: "Save", + backNoSave: "Back", + backendTitle: "Backend", + backendSubtitle: "Subtitle", + backendHelp: "Help", + }, + }); + + expect(promptBackendCategorySettings).toHaveBeenCalledWith( + { fetchTimeoutMs: 5000 }, + sessionSyncCategory, + null, + ); + expect(buildBackendSettingsPreview).toHaveBeenNthCalledWith( + 2, + { fetchTimeoutMs: 2500 }, + ui, + "fetchTimeoutMs", + { highlightPreviewToken: expect.any(Function) }, + ); + expect(result).toEqual({ fetchTimeoutMs: 2500 }); + }); }); diff --git a/test/import-export.test.ts b/test/import-export.test.ts index 2ae3392c..c318bc68 100644 --- a/test/import-export.test.ts +++ b/test/import-export.test.ts @@ -1,9 +1,13 @@ +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { exportAccountsToFile, mergeImportedAccounts, readImportFile, } from "../lib/storage/import-export.js"; +import { removeWithRetry } from "./helpers/remove-with-retry.js"; describe("import export helpers", () => { it("merges imported accounts with dedupe guardrails", () => { @@ -45,4 +49,40 @@ describe("import export helpers", () => { }), ).rejects.toThrow("No accounts to export"); }); + + it("writes exports through a staged temp file and removes temp artifacts", async () => { + const root = await fs.mkdtemp(join(tmpdir(), "codex-import-export-")); + const resolvedPath = join(root, "accounts.json"); + const logInfo = vi.fn(); + + try { + await exportAccountsToFile({ + resolvedPath, + force: true, + storage: { + version: 3, + accounts: [{ refreshToken: "token-a" }], + activeIndex: 0, + activeIndexByFamily: {}, + }, + logInfo, + }); + + const written = JSON.parse(await fs.readFile(resolvedPath, "utf-8")) as { + accounts: Array<{ refreshToken: string }>; + }; + const tempArtifacts = (await fs.readdir(root)).filter((entry) => + entry.endsWith(".tmp"), + ); + + expect(written.accounts).toEqual([{ refreshToken: "token-a" }]); + expect(tempArtifacts).toEqual([]); + expect(logInfo).toHaveBeenCalledWith("Exported accounts", { + path: resolvedPath, + count: 1, + }); + } finally { + await removeWithRetry(root, { recursive: true, force: true }); + } + }); }); diff --git a/test/runtime-services.test.ts b/test/runtime-services.test.ts index 20491340..4cfe1101 100644 --- a/test/runtime-services.test.ts +++ b/test/runtime-services.test.ts @@ -45,6 +45,44 @@ describe("runtime services helpers", () => { expect(result.liveAccountSyncPath).toBe("/tmp/a"); }); + it("warns and keeps the previous path when busy retries are exhausted", async () => { + vi.useFakeTimers(); + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + const syncToPath = vi.fn(async () => { + throw error; + }); + const currentSync = { stop: vi.fn(), syncToPath }; + const logWarn = vi.fn(); + + try { + const resultPromise = ensureLiveAccountSyncState({ + enabled: true, + targetPath: "/tmp/new", + currentSync, + currentPath: "/tmp/old", + createSync: vi.fn(), + registerCleanup: vi.fn(), + logWarn, + pluginName: "plugin", + }); + + await vi.runAllTimersAsync(); + const result = await resultPromise; + + expect(syncToPath).toHaveBeenCalledTimes(3); + expect(logWarn).toHaveBeenCalledWith( + "[plugin] Live account sync path switch failed due to transient filesystem locks; keeping previous watcher.", + ); + expect(result).toEqual({ + liveAccountSync: currentSync, + liveAccountSyncPath: "/tmp/old", + }); + } finally { + vi.useRealTimers(); + } + }); + it("recreates refresh guardian when config changes and clears when disabled", () => { const oldGuardian = { stop: vi.fn(), start: vi.fn() }; const createGuardian = vi.fn(() => ({ stop: vi.fn(), start: vi.fn() })); From ddbc67076e42a2b45d69ab04b1d263c15c8ad02e Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Mon, 23 Mar 2026 04:53:15 +0800 Subject: [PATCH 375/376] test: harden experimental sync target loader --- .../experimental-sync-target-entry.ts | 1 - test/experimental-sync-target-entry.test.ts | 47 +++++++++++++++++-- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/lib/codex-manager/experimental-sync-target-entry.ts b/lib/codex-manager/experimental-sync-target-entry.ts index f72201a3..80df8ca1 100644 --- a/lib/codex-manager/experimental-sync-target-entry.ts +++ b/lib/codex-manager/experimental-sync-target-entry.ts @@ -52,7 +52,6 @@ export async function loadExperimentalSyncTargetEntry(params: { "EBUSY", "EPERM", "EAGAIN", - "ENOTEMPTY", "EACCES", ]), maxAttempts: 4, diff --git a/test/experimental-sync-target-entry.test.ts b/test/experimental-sync-target-entry.test.ts index e0cbff9c..9fe2afa9 100644 --- a/test/experimental-sync-target-entry.test.ts +++ b/test/experimental-sync-target-entry.test.ts @@ -1,5 +1,20 @@ import { describe, expect, it, vi } from "vitest"; import { loadExperimentalSyncTargetEntry } from "../lib/codex-manager/experimental-sync-target-entry.js"; +import type { OcChatgptTargetDetectionResult } from "../lib/oc-chatgpt-target-detection.js"; + +function createDetectedTarget(): OcChatgptTargetDetectionResult { + return { + kind: "target", + descriptor: { + scope: "global", + root: "C:\\.opencode", + accountPath: "C:\\.opencode\\openai-codex-accounts.json", + backupRoot: "C:\\.opencode\\backups", + source: "default-global", + resolution: "accounts", + }, + }; +} describe("experimental sync target entry", () => { it("delegates retrying file read and normalization through the target loader", async () => { @@ -11,7 +26,7 @@ describe("experimental sync target entry", () => { const result = await loadExperimentalSyncTargetEntry({ loadExperimentalSyncTargetState, - detectTarget: () => ({ kind: "target" }) as never, + detectTarget: createDetectedTarget, readFileWithRetry: vi.fn(async () => "{}"), normalizeAccountStorage: vi.fn(() => null), sleep: vi.fn(async () => undefined), @@ -44,7 +59,7 @@ describe("experimental sync target entry", () => { await loadExperimentalSyncTargetEntry({ loadExperimentalSyncTargetState, - detectTarget: () => ({ kind: "target" }) as never, + detectTarget: createDetectedTarget, readFileWithRetry, normalizeAccountStorage, sleep, @@ -52,10 +67,36 @@ describe("experimental sync target entry", () => { expect(capturedReadJson).toBeDefined(); expect(readFileWithRetry).toHaveBeenCalledWith("C:\\state.json", { - retryableCodes: new Set(["EBUSY", "EPERM", "EAGAIN", "ENOTEMPTY", "EACCES"]), + retryableCodes: new Set(["EBUSY", "EPERM", "EAGAIN", "EACCES"]), maxAttempts: 4, sleep, }); expect(normalizeAccountStorage).toHaveBeenCalledWith({ hello: "world" }); }); + + it("propagates malformed json parse failures to the caller", async () => { + const readFileWithRetry = vi.fn(async () => "not-valid-json{{{"); + const normalizeAccountStorage = vi.fn(() => null); + + const loadExperimentalSyncTargetState = vi.fn(async (args) => { + await args.readJson("C:\\state.json"); + return { + kind: "target" as const, + detection: { kind: "target" as const }, + destination: null, + }; + }); + + await expect( + loadExperimentalSyncTargetEntry({ + loadExperimentalSyncTargetState, + detectTarget: createDetectedTarget, + readFileWithRetry, + normalizeAccountStorage, + sleep: vi.fn(async () => undefined), + }), + ).rejects.toThrow(SyntaxError); + + expect(normalizeAccountStorage).not.toHaveBeenCalled(); + }); }); From 16e09d1268ef33848e80675ff2ee9cbe38c81136 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:50:54 +0800 Subject: [PATCH 376/376] Fix release branch review follow-ups --- lib/named-backup-export.ts | 4 ++-- lib/runtime/auth-facade.ts | 20 ++++++++++++++++-- lib/storage/import-export.ts | 5 ++++- test/import-export.test.ts | 26 ++++++++++++++++++++++++ test/index-retry.test.ts | 28 +++++++++++++++++--------- test/runtime-account-selection.test.ts | 19 ++++++++++++----- 6 files changed, 83 insertions(+), 19 deletions(-) diff --git a/lib/named-backup-export.ts b/lib/named-backup-export.ts index 8e8789ea..4e86c624 100644 --- a/lib/named-backup-export.ts +++ b/lib/named-backup-export.ts @@ -229,12 +229,12 @@ export async function exportNamedBackupFile( const destination = resolveNamedBackupPath(name, storagePath); const backupRoot = getNamedBackupRoot(storagePath); const exportKey = normalizePathForComparison(destination); - if (!options?.force && inFlightNamedBackupExports.has(exportKey)) { + if (inFlightNamedBackupExports.has(exportKey)) { throw new Error(`File already exists: ${destination}`); } + inFlightNamedBackupExports.add(exportKey); assertWithinDirectory(dirname(backupRoot), backupRoot); await fs.mkdir(backupRoot, { recursive: true }); - inFlightNamedBackupExports.add(exportKey); try { await dependencies.exportAccounts( destination, diff --git a/lib/runtime/auth-facade.ts b/lib/runtime/auth-facade.ts index d49cde16..3d8ed169 100644 --- a/lib/runtime/auth-facade.ts +++ b/lib/runtime/auth-facade.ts @@ -19,12 +19,28 @@ export async function runRuntimeOAuthFlow( pluginName: string; }, ): Promise { + const pluginPrefix = `[${deps.pluginName}]`; + const prefixLogMessage = ( + message: string, + options?: { leadingNewline?: boolean }, + ): string => { + if ( + message.startsWith(pluginPrefix) || + message.startsWith(`\n${pluginPrefix}`) + ) { + return message; + } + return options?.leadingNewline + ? `\n${pluginPrefix} ${message}` + : `${pluginPrefix} ${message}`; + }; return deps.runBrowserOAuthFlow({ forceNewLogin, manualModeLabel: deps.manualModeLabel, logInfo: deps.logInfo, - logDebug: deps.logDebug, - logWarn: deps.logWarn, + logDebug: (message) => deps.logDebug(prefixLogMessage(message)), + logWarn: (message) => + deps.logWarn(prefixLogMessage(message, { leadingNewline: true })), }); } diff --git a/lib/storage/import-export.ts b/lib/storage/import-export.ts index 9f119868..2178d80d 100644 --- a/lib/storage/import-export.ts +++ b/lib/storage/import-export.ts @@ -130,13 +130,16 @@ export function mergeImportedAccounts(params: { } const deduplicatedAccounts = params.deduplicateAccounts(merged); + const deduplicatedExistingAccounts = + params.deduplicateAccounts(existingAccounts); const newStorage: AccountStorageV3 = { version: 3, accounts: deduplicatedAccounts, activeIndex: existingActiveIndex, activeIndexByFamily: params.existing?.activeIndexByFamily, }; - const importedCount = deduplicatedAccounts.length - existingAccounts.length; + const importedCount = + deduplicatedAccounts.length - deduplicatedExistingAccounts.length; const skippedCount = params.imported.accounts.length - importedCount; return { newStorage, diff --git a/test/import-export.test.ts b/test/import-export.test.ts index c318bc68..e55db43c 100644 --- a/test/import-export.test.ts +++ b/test/import-export.test.ts @@ -32,6 +32,32 @@ describe("import export helpers", () => { expect(result.imported).toBe(1); }); + it("counts imports against deduplicated existing storage", () => { + const result = mergeImportedAccounts({ + existing: { + version: 3, + accounts: [{ refreshToken: "a" }, { refreshToken: "a" }], + activeIndex: 0, + activeIndexByFamily: {}, + }, + imported: { + version: 3, + accounts: [{ refreshToken: "b" }], + activeIndex: 0, + activeIndexByFamily: {}, + }, + maxAccounts: 10, + deduplicateAccounts: (accounts) => + Array.from( + new Map(accounts.map((account) => [account.refreshToken, account])).values(), + ), + }); + + expect(result.total).toBe(2); + expect(result.imported).toBe(1); + expect(result.skipped).toBe(0); + }); + it("throws for invalid import payloads and empty exports", async () => { await expect( readImportFile({ diff --git a/test/index-retry.test.ts b/test/index-retry.test.ts index cb027b75..12d43a75 100644 --- a/test/index-retry.test.ts +++ b/test/index-retry.test.ts @@ -324,15 +324,25 @@ vi.mock("../lib/accounts.js", async () => { }; }); -vi.mock("../lib/storage.js", () => ({ - getStoragePath: () => "", - loadAccounts: async () => null, - saveAccounts: async () => {}, - setStoragePath: () => {}, - setStorageBackupEnabled: () => {}, - exportAccounts: async () => {}, - importAccounts: async () => ({ imported: 0, total: 0 }), -})); +vi.mock("../lib/storage.js", async () => { + const actual = await vi.importActual("../lib/storage.js"); + return { + ...actual, + getStoragePath: () => "", + loadAccounts: async () => null, + saveAccounts: async () => {}, + withAccountStorageTransaction: async ( + handler: ( + loadedStorage: null, + persist: (storage: unknown) => Promise, + ) => Promise, + ): Promise => handler(null, async (_storage: unknown) => {}), + setStoragePath: () => {}, + setStorageBackupEnabled: () => {}, + exportAccounts: async () => {}, + importAccounts: async () => ({ imported: 0, total: 0 }), + }; +}); vi.mock("../lib/recovery.js", () => ({ createSessionRecoveryHook: () => null, diff --git a/test/runtime-account-selection.test.ts b/test/runtime-account-selection.test.ts index 66ffb3f9..38135abd 100644 --- a/test/runtime-account-selection.test.ts +++ b/test/runtime-account-selection.test.ts @@ -24,6 +24,15 @@ function createTokens(): TokenSuccess { }; } +function createDeps(logInfo = vi.fn()) { + return { + envAccountId: process.env.CODEX_AUTH_ACCOUNT_ID, + logInfo, + getAccountIdCandidates, + selectBestAccountCandidate, + }; +} + describe("resolveAccountSelection", () => { beforeEach(() => { vi.clearAllMocks(); @@ -39,7 +48,7 @@ describe("resolveAccountSelection", () => { const tokens = createTokens(); const logInfo = vi.fn(); - const result = resolveAccountSelection(tokens, { logInfo }); + const result = resolveAccountSelection(tokens, createDeps(logInfo)); expect(result).toEqual({ ...tokens, @@ -58,7 +67,7 @@ describe("resolveAccountSelection", () => { vi.mocked(getAccountIdCandidates).mockReturnValueOnce([]); const tokens = createTokens(); - const result = resolveAccountSelection(tokens, { logInfo: vi.fn() }); + const result = resolveAccountSelection(tokens, createDeps()); expect(result).toBe(tokens); expect(selectBestAccountCandidate).not.toHaveBeenCalled(); @@ -75,7 +84,7 @@ describe("resolveAccountSelection", () => { ]); const tokens = createTokens(); - const result = resolveAccountSelection(tokens, { logInfo: vi.fn() }); + const result = resolveAccountSelection(tokens, createDeps()); expect(result).toEqual({ ...tokens, @@ -112,7 +121,7 @@ describe("resolveAccountSelection", () => { vi.mocked(selectBestAccountCandidate).mockReturnValueOnce(candidates[1]); const tokens = createTokens(); - const result = resolveAccountSelection(tokens, { logInfo: vi.fn() }); + const result = resolveAccountSelection(tokens, createDeps()); expect(selectBestAccountCandidate).toHaveBeenCalledWith(candidates); expect(result).toEqual({ @@ -154,7 +163,7 @@ describe("resolveAccountSelection", () => { vi.mocked(selectBestAccountCandidate).mockReturnValueOnce(null); const tokens = createTokens(); - const result = resolveAccountSelection(tokens, { logInfo: vi.fn() }); + const result = resolveAccountSelection(tokens, createDeps()); expect(selectBestAccountCandidate).toHaveBeenCalledWith(candidates); expect(result).toBe(tokens);