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.2.0",
"version": "2.2.1",
"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: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,8 @@ codex-multi-auth doctor --json

## Release Notes

- Current stable: [docs/releases/v2.2.0.md](docs/releases/v2.2.0.md) — install via `npm i -g codex-multi-auth`
- Current stable: [docs/releases/v2.2.1.md](docs/releases/v2.2.1.md) — install via `npm i -g codex-multi-auth`
- Previous stable: [docs/releases/v2.2.0.md](docs/releases/v2.2.0.md)
- 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)
Expand Down
3 changes: 2 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ Public documentation for the `codex-multi-auth` Codex CLI multi-account OAuth ma

| Document | Focus |
| --- | --- |
| [releases/v2.2.0.md](releases/v2.2.0.md) | Current stable release notes (install via `npm i -g codex-multi-auth`) |
| [releases/v2.2.1.md](releases/v2.2.1.md) | Current stable release notes (install via `npm i -g codex-multi-auth`) |
| [releases/v2.2.0.md](releases/v2.2.0.md) | Prior stable release notes |
| [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 |
Expand Down
104 changes: 104 additions & 0 deletions docs/releases/v2.2.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# v2.2.1

Patch release. A full deep audit of the v2.2.0 tree (six parallel auditors across
auth, the runtime proxy/rotation, scripts/bins, quota/policy, storage, and the
codex-manager CLI, plus a live Windows/PowerShell repro) surfaced bugs that
escaped pre-release review. This release fixes 4 HIGH and 6 MEDIUM findings, 9 of
12 LOW findings, and the issues raised across two rounds of automated review.

The headline fix: the `mcodex` launcher (the v2.2.0 flagship) was a bash script
shipped as a Windows bin and could not start on Windows when a WSL stub shadowed
git-bash on PATH. It is now a pure Node launcher with zero bash dependency.

## Install

```bash
npm i -g codex-multi-auth@latest
```

## HIGH

- **mcodex is now a Node launcher (Windows-fatal fix).** `scripts/mcodex` was
`#!/usr/bin/env bash`; npm's generated `.cmd`/`.ps1` shim invoked bare `bash`, so
when the WSL stub (`System32\bash.exe`) or the WindowsApps app-execution alias
resolved before git-bash, `mcodex` died with `HCS_E_SERVICE_NOT_AVAILABLE`. The
launcher is rewritten in Node (`scripts/mcodex.js`): zero bash dependency on the
default forward path, `tmux`/`watch` invoked as argv arrays (no shell string
interpolation), and graceful degradation with the same friendly messages when
those POSIX tools are absent. The direct-run gate canonicalizes symlinks so the
launcher still runs when invoked through an npm-created symlink bin.
Comment on lines +21 to +29

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

dial back the tmux claim in these release notes.

the node rewrite is the real fix here, but this paragraph reads like tmux forwarding is fully corrected too. the pr notes still carry a tmux positional-args gap as follow-up, so saying tmux is handled cleanly overstates the shipped behavior. keep the windows/bash fix, signal relay, and symlink-gate details, but avoid implying full tmux argv fidelity until that lands. as per coding guidelines, "keep README, SECURITY, and docs consistent with actual CLI flags and workflows."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/releases/v2.2.1.md` around lines 21 - 29, The release note overstates
tmux handling; update the paragraph in docs/releases/v2.2.1.md to keep the
Windows/bash fix, Node rewrite (scripts/mcodex.js / scripts/mcodex), signal
relay, and symlink canonicalization details but remove or qualify any claim that
tmux positional-args are fully corrected—instead state that tmux/watch are
invoked as argv arrays to avoid shell interpolation where possible while noting
a follow-up is required to fully preserve tmux positional-argument fidelity.

- **OAuth concurrent-login isolation.** The local callback server stored the
authorization `code`/`state` on the shared `http.Server` instance, so two logins
in one process could cross-bind callback state. Capture now lives in per-call
closures.
- **Capability matrix reads the correct key.** `model-capability-matrix` read
capability snapshots/boosts with the sha256 account key while the store is
written under the entitlement key, so the matrix reported every account as
supporting every model. It now reads with the entitlement key, matching the
write path.
- **Storage transaction deadlock.** A flagged-storage backup recovery that ran
inside an already-held storage lock re-acquired the global mutex and deadlocked,
wedging all subsequent account/token saves (reachable via the doctor restore
flow). Lock ownership is now tracked so recovery persists without re-locking.

## MEDIUM

- **Status tone precedence.** A failed live health check whose detail also carried
a quota percentage could render the account's prefix green ("working") because
the failure keyword was trapped inside the `(NN%)` segment. The tone now
considers the whole detail, so a real failure always renders red.
- **`workspace` index validation.** `codex-multi-auth workspace 1.9` (or `2abc`)
was silently truncated to account 1/2 by `parseInt`. Non-integer indices are now
rejected with a clear "must be a positive integer" message, matching `switch`.
- **Unsupported-model classification.** A "the model … is not currently available
for this ChatGPT account" response was classified as a transient outage instead
of an entitlement block on one code path; the normalized wording is now detected
consistently across the probe/forecast/report/check surfaces.
- **Manual pin preserved on restore.** The combined account+flagged storage
transaction dropped `pinnedAccountIndex`/`affinityGeneration` when cloning, so a
doctor restore erased the user's manual pin. Both fields are now carried through.
- **Secret directory permissions.** The account-storage and quota-cache
directories are created `0o700` (and re-asserted on POSIX) instead of relying on
the umask, so they are not world-listable.
- **Forecast no longer recommends a blocked account.** Policy-blocked and
token-exhausted accounts were eligible for "pick shortest wait"; they are now
excluded, and the forecast returns no recommendation with a clear reason when
none are ready.

## LOW and follow-up hardening

The same audit produced a series of lower-severity fixes, all included here:

- **Concurrency / Windows filesystem.** The local-client-token store serializes
its full read-modify-write and retries the complete transient lock taxonomy
(`EBUSY`/`EPERM`/`EAGAIN`/`ENOTEMPTY`/`EACCES`) on rename; `lastUsedAt` writes on
the bearer-verify hot path are debounced so steady-state verification stays
in-memory. The Codex CLI state cache honors `forceRefresh` even with a load in
flight, guarded by a load generation so a slow stale read can't overwrite a
fresh snapshot. The runtime proxy short-circuits storage re-reads on unchanged
mtime/size and checks authorization before path/method (401 before 404).
- **Config save coordination.** The env-path config save now retries a transient
`stat` lock and serializes its read-modify-write through a cross-process file
lock (modeled on the refresh-lease coordinator) in addition to the in-process
queue and the mtime compare-and-swap, closing the lost-update window.
- **Routing mutex selection race.** With `routingMutex="enabled"`, account
selection and the cursor commit now run inside a single, reentrant mutex
acquisition, so concurrent requests can no longer read the same cursor and
stampede the same account. Legacy mode is unchanged.
- **Smaller fixes.** `clampIndex` floors fractional indices and coerces `NaN`;
the `mcodex` launcher relays `SIGTERM`/`SIGINT` to its spawned child;
capability-policy eviction is LRU; the Codex bin resolver skips any PATH
candidate inside its own wrapper directory; secret directories re-assert
`0o700`; and `styleQuotaSummary` clamps out-of-range percentages.

Two findings are documented as intentional rather than changed: the device-auth
endpoint's bare 403/404 responses are its non-RFC-8628 "authorization pending"
signal (the poll already exits at the server deadline), and runtime budgets are
deliberately soft/eventually-consistent under concurrency.

## Verification

Full test suite green (4,300+ tests, 40+ new regression cases); typecheck and
lint clean; the Node `mcodex` launcher, the `workspace` index guard, the live
`check`/`best`/`forecast` paths, and `verify --all` (the storage transaction
path) were all exercised against a real account on Windows/PowerShell.
15 changes: 15 additions & 0 deletions lib/auth/device-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,21 @@ function formatWaitBudget(timeoutMs: number): string {
return `${totalMinutes} minute${totalMinutes === 1 ? "" : "s"}`;
}

// The OpenAI Codex device-code token endpoint is NOT RFC 8628 compliant: it
// does not return a 400 + {"error":"authorization_pending"} body while waiting.
// Instead, `POST /api/accounts/deviceauth/token` returns a bare 403 while the
// user has not yet approved the code in the browser, and a bare 404 while the
// `device_auth_id` is not yet recognized (propagation lag right after the
// usercode request). Both are normal mid-flight states that must keep polling
// until the user completes the browser step or the deadline/server expiry hits;
// treating either as terminal would abort every login the instant the first
// poll fires, before the user could ever approve. This is exercised by
// test/device-auth.test.ts, where a 403 (and a 404) is the happy-path waiting
// state immediately preceding success. Do not move 403/404 out of the pending
// set without first re-confirming the live endpoint contract, including how a
// user-initiated denial is signaled — if denial is reported with its own
// distinguishable status or body, add that as a separate terminal branch rather
// than reclassifying the bare 403/404 pending responses.
function isPendingStatus(status: number): boolean {
return (
status === 403 ||
Expand Down
25 changes: 12 additions & 13 deletions lib/auth/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ export function startLocalOAuthServer({
state: string;
}): Promise<OAuthServerInfo> {
let pollAborted = false;
// Capture the authorization code/state in per-call closure variables rather
// than mutating the shared http.Server instance. Two logins in the same
// process previously cross-bound callback state via server._lastCode/
// _lastState; isolating them here keeps concurrent server instances
// independent.
let capturedCode: string | undefined;
let capturedState: string | undefined;
const server = http.createServer((req, res) => {
try {
const url = new URL(req.url || "", AUTH_REDIRECT.origin);
Expand Down Expand Up @@ -66,18 +73,14 @@ export function startLocalOAuthServer({
"default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; script-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
);
res.end(successHtml);
const trackedServer = server as http.Server & {
_lastCode?: string;
_lastState?: string;
};
if (trackedServer._lastCode) {
if (capturedCode) {
logWarn(
"Duplicate OAuth callback received; preserving first authorization code",
);
return;
}
trackedServer._lastCode = code;
trackedServer._lastState = state;
capturedCode = code;
capturedState = state;
} catch (err) {
logError(
`Request handler error: ${(err as Error)?.message ?? String(err)}`,
Expand Down Expand Up @@ -107,13 +110,9 @@ export function startLocalOAuthServer({
new Promise<void>((r) => setTimeout(r, POLL_INTERVAL_MS));
for (let i = 0; i < maxIterations; i++) {
if (pollAborted) return null;
const trackedServer = server as http.Server & {
_lastCode?: string;
_lastState?: string;
};
const lastCode = trackedServer._lastCode;
const lastCode = capturedCode;
if (lastCode) {
if (trackedServer._lastState !== expectedState) {
if (capturedState !== expectedState) {
logWarn(
"Discarding OAuth callback due to state mismatch in waitForCode",
);
Expand Down
5 changes: 5 additions & 0 deletions lib/capability-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ export class CapabilityPolicyStore {
const key = makeKey(accountKey, model);
if (!key) return;
const existing = this.entries.get(key);
// Delete-then-set so the entry moves to the end of Map iteration order,
// making eviction LRU (least-recently-recorded) rather than FIFO.
this.entries.delete(key);
this.entries.set(key, {
successes: (existing?.successes ?? 0) + 1,
failures: Math.max(0, (existing?.failures ?? 0) - 1),
Expand All @@ -88,6 +91,7 @@ export class CapabilityPolicyStore {
const key = makeKey(accountKey, model);
if (!key) return;
const existing = this.entries.get(key);
this.entries.delete(key);
this.entries.set(key, {
successes: existing?.successes ?? 0,
failures: (existing?.failures ?? 0) + 1,
Expand All @@ -103,6 +107,7 @@ export class CapabilityPolicyStore {
const key = makeKey(accountKey, model);
if (!key) return;
const existing = this.entries.get(key);
this.entries.delete(key);
this.entries.set(key, {
successes: existing?.successes ?? 0,
failures: (existing?.failures ?? 0) + 1,
Expand Down
35 changes: 28 additions & 7 deletions lib/codex-cli/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ export interface CodexCliState {
let cache: CodexCliState | null = null;
let cacheLoadedAt = 0;
let inFlightLoadPromise: Promise<CodexCliState | null> | null = null;
// Monotonic load generation. forceRefresh lets loads overlap, so a slower stale
// (earlier) read must not overwrite the shared cache committed by a newer one.
// Each readTask captures its generation and only commits cache/cacheLoadedAt when
// it is still the latest; the caller always receives its own fresh return value.
let latestLoadGeneration = 0;
const emittedWarnings = new Set<string>();

function isRetryableFsError(error: unknown): boolean {
Expand Down Expand Up @@ -393,11 +398,23 @@ export async function loadCodexCliState(
if (!options?.forceRefresh && cache && now - cacheLoadedAt < CACHE_TTL_MS) {
return cache;
}
if (inFlightLoadPromise) {
// A forceRefresh caller must observe fresh disk state, so it must not be
// satisfied by an in-flight load that may have been started without
// forceRefresh (and could resolve stale/coalesced data). Only non-forced
// callers coalesce onto the in-flight promise; forced callers fall through
// and start their own fresh read below.
if (!options?.forceRefresh && inFlightLoadPromise) {
return inFlightLoadPromise;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

const readTask = async (): Promise<CodexCliState | null> => {
// Claim a generation; only the latest load is allowed to commit the cache.
const loadGeneration = ++latestLoadGeneration;
const commitCache = (value: CodexCliState | null): void => {
if (loadGeneration === latestLoadGeneration) {
cache = value;
}
};
const accountsPath = getCodexCliAccountsPath();
const authPath = getCodexCliAuthPath();
incrementCodexCliMetric("readAttempts");
Expand All @@ -406,7 +423,7 @@ export async function loadCodexCliState(
const hasAuthPath = existsSync(authPath);
if (!hasAccountsPath && !hasAuthPath) {
incrementCodexCliMetric("readMisses");
cache = null;
commitCache(null);
return null;
}

Expand Down Expand Up @@ -434,7 +451,7 @@ export async function loadCodexCliState(
email: state.activeEmail,
}),
});
cache = state;
commitCache(state);
return state;
}
log.warn("Codex CLI accounts payload is malformed", {
Expand Down Expand Up @@ -475,7 +492,7 @@ export async function loadCodexCliState(
email: state.activeEmail,
}),
});
cache = state;
commitCache(state);
return state;
}
log.warn("Codex CLI auth payload is malformed", {
Expand All @@ -494,7 +511,7 @@ export async function loadCodexCliState(
}

incrementCodexCliMetric("readFailures");
cache = null;
commitCache(null);
return null;
} catch (error) {
incrementCodexCliMetric("readFailures");
Expand All @@ -504,10 +521,14 @@ export async function loadCodexCliState(
path: hasAccountsPath ? accountsPath : authPath,
error: String(error),
});
cache = null;
commitCache(null);
return null;
} finally {
cacheLoadedAt = Date.now();
// Only the latest load advances the shared cache timestamp, mirroring
// commitCache, so a slow stale read can't reset the TTL window.
if (loadGeneration === latestLoadGeneration) {
cacheLoadedAt = Date.now();
}
}
};

Expand Down
16 changes: 12 additions & 4 deletions lib/codex-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,10 @@ function styleQuotaSummary(summary: string): string {
return stylePromptText(segment, "muted");
}
const windowLabel = match[1] ?? "";
const leftPercent = Number.parseInt(match[2] ?? "", 10);
const leftPercent = Math.max(
0,
Math.min(100, Number.parseInt(match[2] ?? "", 10)),
);
if (!Number.isFinite(leftPercent)) {
return stylePromptText(segment, "muted");
}
Expand All @@ -419,9 +422,14 @@ export function styleAccountDetailText(
const quota = (quotaMatch[2] ?? "").trim();
const suffix = (quotaMatch[3] ?? "").trim();

const prefixTone: PromptTone = /failed|error/i.test(prefix)
// danger wins across the WHOLE detail: a failure keyword anywhere — even
// trapped inside the (…%) quota segment, e.g.
// "signed in and working (live check failed: … 0%)" — must keep the prefix
// red, never let a "working"/"ok" prefix render green over a real failure.
const detailHasFailure = /failed|error|rate-limited/i.test(compact);
const prefixTone: PromptTone = detailHasFailure
? "danger"
: /ok|working|succeeded|valid/i.test(prefix)
: /\b(ok|working|succeeded|valid)\b/i.test(prefix)
? "success"
: fallbackTone;
const suffixTone: PromptTone =
Expand All @@ -445,7 +453,7 @@ export function styleAccountDetailText(
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 (/ok|working|succeeded|valid/i.test(compact))
if (/\b(ok|working|succeeded|valid)\b/i.test(compact))
return stylePromptText(compact, "success");
return stylePromptText(compact, fallbackTone);
}
Expand Down
15 changes: 14 additions & 1 deletion lib/codex-manager/commands/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,16 @@ export async function runWorkspaceCommand(
return 1;
}

// Require a plain positive integer (matches `switch`): Number.parseInt would
// silently truncate "1.9" -> 1 or "2abc" -> 2 and operate on the wrong
// account, so reject anything that isn't all digits.
if (!/^\d+$/.test(accountArg.trim())) {
logError(`Invalid account index (must be a positive integer): ${accountArg}`);
return 1;
}
const parsedAccount = Number.parseInt(accountArg, 10);
if (!Number.isFinite(parsedAccount) || parsedAccount < 1) {
logError(`Invalid account index: ${accountArg}`);
logError(`Invalid account index (must be a positive integer): ${accountArg}`);
return 1;
}

Expand Down Expand Up @@ -85,6 +92,12 @@ export async function runWorkspaceCommand(
return 0;
}

if (!/^\d+$/.test(workspaceArg.trim())) {
logError(
`Invalid workspace index (must be a positive integer). Valid range: 1-${workspaces.length}`,
);
return 1;
}
const parsedWorkspace = Number.parseInt(workspaceArg, 10);
if (
!Number.isFinite(parsedWorkspace) ||
Expand Down
Loading