Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,6 @@ test-results.md
# local env files
.env
.env.local

# OMC session scratch (never publish)
.omc/
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
33 changes: 21 additions & 12 deletions docs/releases/v2.1.13-beta.3.md → docs/releases/v2.2.0.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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 <index>` (no silent float truncation).
- Bump `vitest` to `^4.1.8` (dev-only) to clear GHSA-5xrq-8626-4rwp.

## Verification
Expand Down
18 changes: 12 additions & 6 deletions lib/codex-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Comment thread
ndycode marked this conversation as resolved.
detail: string,
fallbackTone: PromptTone = "muted",
): string {
Expand All @@ -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[] = [];
Expand All @@ -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");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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);
Expand Down
7 changes: 7 additions & 0 deletions lib/codex-manager/commands/switch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
6 changes: 6 additions & 0 deletions lib/storage/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
72 changes: 72 additions & 0 deletions test/codex-manager-detail-tone.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
12 changes: 12 additions & 0 deletions test/codex-manager-switch-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => ({
Expand Down
8 changes: 8 additions & 0 deletions test/paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down