diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 744e06b45..35d0f2e54 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codex-multi-auth", - "version": "2.1.13-beta.3", + "version": "2.2.0", "description": "Install and operate codex-multi-auth for the official @openai/codex CLI with multi-account OAuth rotation, switching, health checks, and recovery tools.", "interface": { "composerIcon": "./assets/codex-multi-auth-icon.svg" diff --git a/.gitignore b/.gitignore index 3e690535b..8b5979914 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ test-results.md # local env files .env .env.local + +# OMC session scratch (never publish) +.omc/ diff --git a/AGENTS.md b/AGENTS.md index 6dce0fa6b..180bce872 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ Generated: 2026-04-25 Commit: a87e005 Branch: main -Package version: 2.1.13-beta.3 +Package version: 2.2.0 ## OVERVIEW diff --git a/README.md b/README.md index 56c7d4876..cf6167971 100644 --- a/README.md +++ b/README.md @@ -383,9 +383,9 @@ codex-multi-auth doctor --json ## Release Notes -- Current prerelease: [docs/releases/v2.1.13-beta.3.md](docs/releases/v2.1.13-beta.3.md) — install via `npm i -g codex-multi-auth@beta` -- Current stable: [docs/releases/v2.1.12.md](docs/releases/v2.1.12.md) -- Previous stable: [docs/releases/v2.1.11.md](docs/releases/v2.1.11.md) +- Current stable: [docs/releases/v2.2.0.md](docs/releases/v2.2.0.md) — install via `npm i -g codex-multi-auth` +- Previous stable: [docs/releases/v2.1.12.md](docs/releases/v2.1.12.md) +- Earlier stable: [docs/releases/v2.1.11.md](docs/releases/v2.1.11.md) - Earlier stable: [docs/releases/v2.1.10.md](docs/releases/v2.1.10.md) - Earlier stable: [docs/releases/v2.1.8.md](docs/releases/v2.1.8.md) - Earlier stable: [docs/releases/v2.1.7.md](docs/releases/v2.1.7.md) diff --git a/docs/README.md b/docs/README.md index c82a68178..a5e07d9fe 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,8 +32,8 @@ Public documentation for the `codex-multi-auth` Codex CLI multi-account OAuth ma | Document | Focus | | --- | --- | -| [releases/v2.1.13-beta.3.md](releases/v2.1.13-beta.3.md) | Current prerelease notes (install via `npm i -g codex-multi-auth@beta`) | -| [releases/v2.1.12.md](releases/v2.1.12.md) | Current stable release notes | +| [releases/v2.2.0.md](releases/v2.2.0.md) | Current stable release notes (install via `npm i -g codex-multi-auth`) | +| [releases/v2.1.12.md](releases/v2.1.12.md) | Prior stable release notes | | [releases/v2.1.11.md](releases/v2.1.11.md) | Prior stable release notes | | [releases/v2.1.10.md](releases/v2.1.10.md) | Earlier stable release notes | | [releases/v2.1.9.md](releases/v2.1.9.md) | Earlier stable release notes | diff --git a/docs/releases/v2.1.13-beta.3.md b/docs/releases/v2.2.0.md similarity index 78% rename from docs/releases/v2.1.13-beta.3.md rename to docs/releases/v2.2.0.md index 49d4cb535..2efdc0c25 100644 --- a/docs/releases/v2.1.13-beta.3.md +++ b/docs/releases/v2.2.0.md @@ -1,19 +1,17 @@ -# v2.1.13-beta.3 +# v2.2.0 -Beta prerelease that lands the Phase 1 correctness/security audit (#499), the new -`mcodex` launcher + cached statusline (#500), and a round of pre-release -hardening surfaced by a whole-tree stress test (#503). It carries forward the -cascade OAuth token-invalidation fix from v2.1.13-beta.2, the multi-workspace -support from v2.1.13-beta.1, and the pinned-account 503 diagnostic from -v2.1.13-beta.0. - -This is a **prerelease**. Stable `v2.1.13` will land once the issue #486 root -cause is identified and patched. +Stable release. Promotes the v2.1.13-beta line to stable and adds the `mcodex` +launcher. It bundles the Phase 1 correctness/security audit (#499), the new +`mcodex` launcher + cached statusline (#500), the quota unsupported-model +detail-shape detection (#501/#502), and the hardening surfaced by a whole-tree +stress test (#503). It also carries the cascade OAuth token-invalidation fix, the +multi-workspace support, and the pinned-account 503 diagnostic that shipped +across v2.1.13-beta.0–beta.3. ## Install ```bash -npm i -g codex-multi-auth@beta +npm i -g codex-multi-auth ``` ## mcodex launcher (#500) @@ -94,11 +92,22 @@ Security and correctness hardening across the runtime, storage, and prompt layer - `codex-multi-auth status` / `list` gained `--json` for machine-readable output, with a stable shape whether or not accounts are configured. -## Pre-release hardening (#503) +## Quota detection (#501/#502) + +- Detect an unsupported Codex model from the upstream error `detail` shape (not + just the nested `error` envelope), so a Codex-gated account surfaces a friendly + "Codex unavailable" note across the `best` / `forecast` / `report` / live-check + surfaces instead of leaking the raw upstream "model is not supported" text. +- A genuine transient failure mixed into the probe still surfaces as the real + error (never masked behind the friendly note), so a real outage is not hidden. + +## Post-audit hardening (#503 + follow-ups) - Strip inbound `cookie` / `proxy-authorization` on both egress paths. - Bound the proxy's upstream error-body read (previously unbounded on 4xx/5xx). - Persist `runtime-observability.json` owner-only (`0o600` / dir `0o700`) on POSIX. +- Reject NUL-byte paths in `resolvePath` (defense in depth) and require a strict + integer for `switch ` (no silent float truncation). - Bump `vitest` to `^4.1.8` (dev-only) to clear GHSA-5xrq-8626-4rwp. ## Verification diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 482b4ac71..0446af50a 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -403,7 +403,10 @@ function styleQuotaSummary(summary: string): string { return joinStyledSegments(rendered); } -function styleAccountDetailText( +// Exported for unit tests: tone precedence (danger before warning) is +// security/UX-relevant — a failure whose text contains "unavailable" must +// render red, not be downgraded to yellow. See test/codex-manager-detail-tone.test.ts. +export function styleAccountDetailText( detail: string, fallbackTone: PromptTone = "muted", ): string { @@ -422,10 +425,13 @@ function styleAccountDetailText( ? "success" : fallbackTone; const suffixTone: PromptTone = - /re-login|stale|warning|retry|fallback|unavailable|not available/i.test(suffix) - ? "warning" - : /failed|error/i.test(suffix) - ? "danger" + // danger wins first: a real failure whose text happens to contain + // "unavailable"/"not available" (e.g. a 5xx "service not available") + // must render red, not be downgraded to a yellow warning. + /failed|error/i.test(suffix) + ? "danger" + : /re-login|stale|warning|retry|fallback|unavailable|not available/i.test(suffix) + ? "warning" : "muted"; const chunks: string[] = []; @@ -436,9 +442,9 @@ function styleAccountDetailText( } if (/rate-limited/i.test(compact)) return stylePromptText(compact, "danger"); + if (/failed|error/i.test(compact)) return stylePromptText(compact, "danger"); if (/re-login|stale|warning|fallback|unavailable|not available/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"); return stylePromptText(compact, fallbackTone); diff --git a/lib/codex-manager/commands/switch.ts b/lib/codex-manager/commands/switch.ts index 0733dc7f3..4202a456b 100644 --- a/lib/codex-manager/commands/switch.ts +++ b/lib/codex-manager/commands/switch.ts @@ -36,6 +36,13 @@ export async function runSwitchCommand( return 1; } + // Require a plain positive integer. Number.parseInt would silently truncate + // "1.5" -> 1 (or "2abc" -> 2), selecting a real account from malformed input; + // reject anything that isn't all digits so the index is unambiguous. + if (!/^\d+$/.test(indexArg.trim())) { + (deps.logError ?? console.error)(`Invalid index: ${indexArg}`); + return 1; + } const parsed = Number.parseInt(indexArg, 10); if (!Number.isFinite(parsed) || parsed < 1) { (deps.logError ?? console.error)(`Invalid index: ${indexArg}`); diff --git a/lib/storage/paths.ts b/lib/storage/paths.ts index c03be6de1..5cc069744 100644 --- a/lib/storage/paths.ts +++ b/lib/storage/paths.ts @@ -442,6 +442,12 @@ function canonicalizeExistingPrefix(targetPath: string): string { } export function resolvePath(filePath: string): string { + // Reject NUL bytes up front (defense in depth): a poison byte cannot traverse + // out of an approved root, but it must never reach the fs layer — fail here + // with a clear error rather than letting Node throw deep in a later read/write. + if (filePath.includes(String.fromCharCode(0))) { + throw new Error("Invalid path: contains a NUL byte"); + } let resolved: string; if (filePath.startsWith("~")) { resolved = join(homedir(), filePath.slice(1)); diff --git a/package-lock.json b/package-lock.json index f9a03303d..6e11d5d59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-multi-auth", - "version": "2.1.13-beta.2", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-multi-auth", - "version": "2.1.13-beta.2", + "version": "2.2.0", "bundleDependencies": [ "@codex-ai/plugin", "@codex-ai/sdk" diff --git a/package.json b/package.json index ff5132e81..0b7d722ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-multi-auth", - "version": "2.1.13-beta.3", + "version": "2.2.0", "description": "Codex CLI multi-account OAuth manager with account switching, health checks, runtime rotation, diagnostics, and recovery tools for @openai/codex", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/test/codex-manager-detail-tone.test.ts b/test/codex-manager-detail-tone.test.ts new file mode 100644 index 000000000..dc5cd0a78 --- /dev/null +++ b/test/codex-manager-detail-tone.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { styleAccountDetailText } from "../lib/codex-manager.js"; +import { ANSI } from "../lib/ui/ansi.js"; +import { resetUiRuntimeOptions, setUiRuntimeOptions } from "../lib/ui/runtime.js"; + +// styleAccountDetailText colors a one-line account detail by tone. The +// security/UX-relevant invariant is precedence: a real failure whose text also +// contains "unavailable"/"not available" (e.g. a 5xx "service not available") +// MUST render danger (red), never be downgraded to a warning (yellow). These +// tests pin that the /failed|error/i check wins over the unavailable regex on +// both the compact path and the quota-suffix path. +// +// To make tone assertions deterministic we force the legacy ANSI renderer +// (v2 off) and an interactive stdout, so danger => ANSI.red and +// warning => ANSI.yellow exactly. + +describe("styleAccountDetailText tone precedence", () => { + const stdoutIsTTYDescriptor = Object.getOwnPropertyDescriptor( + process.stdout, + "isTTY", + ); + + beforeEach(() => { + Object.defineProperty(process.stdout, "isTTY", { + value: true, + configurable: true, + }); + setUiRuntimeOptions({ v2Enabled: false }); + }); + + afterEach(() => { + resetUiRuntimeOptions(); + if (stdoutIsTTYDescriptor) { + Object.defineProperty(process.stdout, "isTTY", stdoutIsTTYDescriptor); + } else { + delete (process.stdout as unknown as { isTTY?: boolean }).isTTY; + } + }); + + it("renders danger (red), not warning (yellow), when a failure detail also says 'unavailable'", () => { + const styled = styleAccountDetailText("refresh failed: service unavailable"); + expect(styled).toContain(ANSI.red); + expect(styled).not.toContain(ANSI.yellow); + }); + + it("treats 'not available' the same — danger wins over the warning keyword", () => { + const styled = styleAccountDetailText("token error (not available)"); + // "(not available)" has no percent sign, so this stays on the compact path. + expect(styled).toContain(ANSI.red); + expect(styled).not.toContain(ANSI.yellow); + }); + + it("normalizes whitespace/newlines before matching tone keywords", () => { + const styled = styleAccountDetailText(" probe failed\n — endpoint unavailable "); + expect(styled).toContain(ANSI.red); + expect(styled).not.toContain(ANSI.yellow); + }); + + it("still renders warning (yellow) when only an unavailable keyword is present", () => { + const styled = styleAccountDetailText("Codex not available for this account"); + expect(styled).toContain(ANSI.yellow); + expect(styled).not.toContain(ANSI.red); + }); + + it("applies danger precedence to the quota suffix when it contains both keywords", () => { + // Quota-bearing prefix routes through the (NN%) branch; the suffix carries + // the failure text and must render red despite containing "unavailable". + const styled = styleAccountDetailText("acct (12%) — refresh failed, now unavailable"); + expect(styled).toContain(ANSI.red); + expect(styled).not.toContain(ANSI.yellow); + }); +}); diff --git a/test/codex-manager-switch-command.test.ts b/test/codex-manager-switch-command.test.ts index 9c03d1875..28a1dbaa0 100644 --- a/test/codex-manager-switch-command.test.ts +++ b/test/codex-manager-switch-command.test.ts @@ -67,6 +67,18 @@ describe("runSwitchCommand", () => { ); }); + it.each(["1.5", "2.9", "2abc", "0x2", "1e0", "+1", "-1"])( + "rejects a non-integer index %p instead of truncating it", + async (arg) => { + const deps = createDeps(); + const result = await runSwitchCommand([arg], deps); + expect(result).toBe(1); + expect(deps.logError).toHaveBeenCalledWith(`Invalid index: ${arg}`); + // A rejected index must never persist a selection. + expect(deps.persistAndSyncSelectedAccount).not.toHaveBeenCalled(); + }, + ); + it("persists and reports the selected account", async () => { const deps = createDeps({ persistAndSyncSelectedAccount: vi.fn(async () => ({ diff --git a/test/paths.test.ts b/test/paths.test.ts index c0ba86a72..2e48648e4 100644 --- a/test/paths.test.ts +++ b/test/paths.test.ts @@ -772,6 +772,14 @@ describe("Storage Paths Module", () => { }); describe("resolvePath", () => { + it("rejects a path containing a NUL byte", () => { + // Defense in depth: a poison byte must be refused here, not deep in fs. + expect(() => resolvePath(`~/.codex/${String.fromCharCode(0)}evil.json`)).toThrow( + /NUL byte/i, + ); + expect(() => resolvePath(`${String.fromCharCode(0)}`)).toThrow(/NUL byte/i); + }); + it("should expand tilde to home directory", () => { const result = resolvePath("~/.codex/config.json"); expect(result).toBe(path.join(homedir(), ".codex/config.json"));