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.2",
"version": "2.1.13-beta.3",
"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
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.2
Package version: 2.1.13-beta.3

## OVERVIEW

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ codex-multi-auth doctor --json

## Release Notes

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

| Document | Focus |
| --- | --- |
| [releases/v2.1.13-beta.2.md](releases/v2.1.13-beta.2.md) | Current prerelease notes (install via `npm i -g codex-multi-auth@beta`) |
| [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.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
107 changes: 107 additions & 0 deletions docs/releases/v2.1.13-beta.3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# v2.1.13-beta.3

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.

## Install

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

## mcodex launcher (#500)

A lightweight launcher and cached multi-auth status display for Codex sessions.

- `mcodex` — launch Codex with a cached status line printed before startup
(model, reasoning effort, cwd, active account, quota usage, plan, cache age).
- `mcodex --tmux` — launch inside a tmux session with mouse scrollback.
- `mcodex --tmux --live-accounts` — add a live `codex-multi-auth list` monitor pane.
- `mcodex --monitor` — monitor-only mode.

### Status line

- Reads local `quota-cache.json`, `runtime-observability.json`, and account
storage; never calls OpenAI on launch.
- Refreshes quota data in the background only when the cache is stale (default
10 min, `CODEX_MULTI_AUTH_STATUS_QUOTA_REFRESH_INTERVAL_MS`), behind a lock so
concurrent launches don't double-refresh. Stale refresh locks recover after
10 min.
- Resolves the **per-project** account pool when `perProjectAccounts` is enabled
and Codex CLI sync is off (mirrors the runtime's own account scoping), falling
back to the global pool otherwise. Quota/observability stay global.
- Toggle with `CODEX_MULTI_AUTH_STATUSLINE=0`.

### Hardening

- `MCODEX_MONITOR_INTERVAL` / `MCODEX_TMUX_HISTORY_LIMIT` are validated as numeric
before being interpolated into `watch` / tmux commands (no shell injection).
- `--monitor` / `--live-accounts` fail fast with a clear message when `watch`
isn't installed instead of spawning a broken pane.
- The status path resolves `~` correctly on Windows (`path.sep`, not a hardcoded
`/`), and reads the account pool without blocking the event loop.

## Phase 1 audit remediation (#499)

Security and correctness hardening across the runtime, storage, and prompt layers.

### Security

- **Prompt cache integrity.** Cached Codex instructions are SHA-256 verified; a
tampered cache is discarded, and a legacy entry with no recorded digest is
treated as unverified — it is never fast-path served and never drives a
conditional `304` revalidation (which could otherwise launder un-vetted bytes).
A full fetch mints the first digest; old bytes are kept only as an offline
fallback.
- **Path-traversal defense in recovery.** Stored message/part records are
validated before their ids are used to build filesystem paths; a
parseable-but-unsafe id (e.g. `../poison`) or a non-numeric `time.created` is
quarantined instead of escaping into a traversal read or a `NaN` sort.
- **Loopback-only egress.** The runtime rotation proxy and local bridge bind
loopback-only with no opt-out, and never forward inbound client credentials
(`authorization`, `x-api-key`, `cookie`, `proxy-authorization`) upstream
alongside the managed token. IPv6 loopback (`::1` / `[::1]`) is normalized
consistently for both the socket bind and the emitted base URL.
- **Token/email redaction.** Log, debug-bundle, and status sinks mask tokens and
emails; the debug bundle redacts the home prefix (case- and
separator-insensitive on Windows), strips credentials from config values, and
masks the account id.

### Correctness & resilience

- **No event-loop blocking.** Removed synchronous `Atomics.wait` sleeps from the
config load path and the logger's directory-creation retry; both now retry
without freezing the event loop.
- **Bounded network reads.** Prompt and release-metadata fetches are bounded by
connect+body timeouts that actually cancel a stalled body.
- **Windows filesystem resilience.** Account-store WAL/temp writes, temp cleanup,
backup copy/rename, quota-cache, flagged-store, and export operations retry the
shared transient-lock taxonomy (EBUSY/EPERM/ENOTEMPTY/EACCES/EAGAIN) so an
antivirus/indexer/concurrent-reader lock doesn't fail a valid operation or
strand a secret-bearing temp file.
- **Atomic, self-healing account store.** Writes go through a checksummed WAL +
temp-and-rename; a torn write self-heals on the next read.

### CLI

- `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)

- 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.
- Bump `vitest` to `^4.1.8` (dev-only) to clear GHSA-5xrq-8626-4rwp.

## Verification

Full test suite (4,200+ tests) green; `npm run audit:ci` clean; typecheck and
lint pass.
4 changes: 4 additions & 0 deletions lib/local-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ function forwardHeaders(headers: Headers, runtimeClientApiKey?: string): Headers
// caller's local credential across the bridge boundary and could change which
// auth the runtime proxy evaluates — strip it unconditionally.
result.delete("x-api-key");
// Same contract: never carry an inbound Cookie / proxy-auth header upstream
// alongside the managed token.
result.delete("cookie");
result.delete("proxy-authorization");
// runtime-proxy-03: present the runtime proxy's client token. We replace the
// inbound client's Authorization (already validated by the bridge) rather than
// forwarding it verbatim, so the bridge can authenticate to an auth-enabled
Expand Down
11 changes: 10 additions & 1 deletion lib/quota-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,16 @@ interface QuotaCacheFile {

const QUOTA_CACHE_PATH = join(getCodexMultiAuthDir(), "quota-cache.json");
const QUOTA_CACHE_LABEL = basename(QUOTA_CACHE_PATH);
const RETRYABLE_FS_CODES = new Set(["EBUSY", "EPERM"]);
// Align with the shared FILE_RETRY_CODES taxonomy (lib/fs-retry.ts) so a
// transient Windows lock (AV/indexer/concurrent reader) on the quota cache is
// retried consistently with every other fs path, not just EBUSY/EPERM.
const RETRYABLE_FS_CODES = new Set([
"EBUSY",
"EPERM",
"EAGAIN",
"ENOTEMPTY",
"EACCES",
]);
Comment thread
ndycode marked this conversation as resolved.
let quotaCacheWriteQueue: Promise<void> = Promise.resolve();

function isRetryableFsError(error: unknown): boolean {
Expand Down
69 changes: 63 additions & 6 deletions lib/runtime-rotation-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,10 @@ function createOutboundHeaders(
}
headers.delete("host");
headers.delete("x-api-key");
// Never forward inbound client credentials upstream: a Cookie / proxy-auth
// header would ride along with the managed OAuth Bearer to OpenAI.
headers.delete("cookie");
headers.delete("proxy-authorization");
headers.set("authorization", `Bearer ${accessToken}`);
headers.set(OPENAI_HEADERS.ACCOUNT_ID, accountId);
headers.set(OPENAI_HEADERS.BETA, OPENAI_HEADER_VALUES.BETA_RESPONSES);
Expand Down Expand Up @@ -892,9 +896,62 @@ function parseRetryAfterBodyMs(bodyText: string, now: number): number | null {
return null;
}

async function readErrorBody(response: Response): Promise<string> {
async function readErrorBody(
response: Response,
timeoutMs: number,
maxBytes = 1024 * 1024,
): Promise<string> {
// The outbound fetch's abort timer is cleared once headers arrive, so a
// stalled error body would otherwise hang this handler forever (the success
// path is per-chunk stall-bounded; the error path was not). Read the body via
// a reader, bound it by an idle timeout AND a size cap, and cancel the stream
// on timeout/overflow so the socket is released.
const body = response.body;
if (!body || typeof body.getReader !== "function") {
// Fallback for impls without a streamable body: race text() against a timer.
try {
return await withTimeout(
response.text(),
timeoutMs,
() => undefined,
"error body stalled",
);
} catch {
return "";
}
}
const reader = body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
for (;;) {
let idleTimer: ReturnType<typeof setTimeout> | undefined;
const idle = new Promise<never>((_resolve, reject) => {
idleTimer = setTimeout(
() => reject(new Error("error body stalled")),
Math.max(1, timeoutMs),
);
});
let result: Awaited<ReturnType<typeof reader.read>>;
try {
result = await Promise.race([reader.read(), idle]);
} finally {
if (idleTimer) clearTimeout(idleTimer);
}
if (result.done) break;
if (result.value) {
total += result.value.byteLength;
if (total > maxBytes) break; // cap: enough for diagnostics, no OOM
chunks.push(result.value);
}
}
} catch {
// stalled or errored — fall through with whatever we have
} finally {
await reader.cancel().catch(() => undefined);
}
try {
return await response.text();
return Buffer.concat(chunks).toString("utf8");
} catch {
return "";
}
Expand Down Expand Up @@ -1734,7 +1791,7 @@ export async function startRuntimeRotationProxy(
}

if (upstream.status === HTTP_STATUS.TOO_MANY_REQUESTS) {
const bodyText = await readErrorBody(upstream);
const bodyText = await readErrorBody(upstream, streamStallTimeoutMs);
const retryAfterMs =
parseRetryAfterHeaderMs(upstream.headers, now()) ??
parseRetryAfterBodyMs(bodyText, now()) ??
Expand All @@ -1759,7 +1816,7 @@ export async function startRuntimeRotationProxy(
}

if (upstream.status === 402 || upstream.status === HTTP_STATUS.FORBIDDEN) {
const bodyText = await readErrorBody(upstream);
const bodyText = await readErrorBody(upstream, streamStallTimeoutMs);
const errorCode = extractErrorCodeFromBody(bodyText);
if (isWorkspaceDisabledError(upstream.status, errorCode, bodyText)) {
const accountWasEnabled =
Expand Down Expand Up @@ -1856,7 +1913,7 @@ export async function startRuntimeRotationProxy(
}

if (upstream.status === HTTP_STATUS.UNAUTHORIZED) {
const bodyText = await readErrorBody(upstream);
const bodyText = await readErrorBody(upstream, streamStallTimeoutMs);
accountManager.refundToken(refreshed.account, context.family, context.model);
accountManager.recordFailure(refreshed.account, context.family, context.model);
if (isTokenInvalidationError(bodyText)) {
Expand Down Expand Up @@ -1902,7 +1959,7 @@ export async function startRuntimeRotationProxy(
}

if (upstream.status >= 500) {
await readErrorBody(upstream);
await readErrorBody(upstream, streamStallTimeoutMs);
accountManager.refundToken(refreshed.account, context.family, context.model);
accountManager.recordFailure(refreshed.account, context.family, context.model);
accountManager.markAccountCoolingDown(
Expand Down
25 changes: 23 additions & 2 deletions lib/runtime/runtime-observability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,13 +195,34 @@ function ensureSnapshotState(): RuntimeObservabilitySnapshot {
async function writeSnapshot(snapshot: RuntimeObservabilitySnapshot): Promise<void> {
const dir = getCodexMultiAuthDir();
const path = getSnapshotPath();
await fs.mkdir(dir, { recursive: true });
// The snapshot persists account identifiers (lastAccountId/label/index), so
// keep it owner-only on POSIX like the other sensitive writers (logger,
// local-client-tokens). mode is a no-op on win32 (ACL-based).
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
// mkdir's mode only applies to a freshly-created dir; an upgrade path with a
// pre-existing multi-auth dir keeps its old (possibly permissive) perms, so
// re-assert 0o700 on POSIX. Only ENOENT is swallowed (the dir was removed by a
// concurrent process — the snapshot write below will recreate/fail as needed);
// any other chmod failure is surfaced rather than silently leaving a
// world-readable dir to hold account ids/labels.
if (process.platform !== "win32") {
try {
await fs.chmod(dir, 0o700);
} catch (error) {
if ((error as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") {
throw error;
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let lastError: unknown = null;
for (let attempt = 0; attempt < 3; attempt += 1) {
const tempPath = `${path}.${process.pid}.${Date.now()}.${attempt}.tmp`;
let moved = false;
try {
await fs.writeFile(tempPath, JSON.stringify(snapshot, null, 2), "utf-8");
await fs.writeFile(tempPath, JSON.stringify(snapshot, null, 2), {
encoding: "utf-8",
mode: 0o600,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
await fs.rename(tempPath, path);
moved = true;
return;
Expand Down
30 changes: 19 additions & 11 deletions lib/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { existsSync, promises as fs, readFileSync } from "node:fs";
import { basename, dirname, join } from "node:path";
import { ACCOUNT_LIMITS } from "./constants.js";
import { StorageError } from "./errors.js";
import { shouldRetryFileOperation, withFileOperationRetry } from "./fs-retry.js";
import { createLogger } from "./logger.js";
import {
exportNamedBackupFile,
Expand Down Expand Up @@ -273,8 +274,7 @@ async function copyFileWithRetry(
return;
}
const canRetry =
(code === "EPERM" || code === "EBUSY") &&
attempt + 1 < BACKUP_COPY_MAX_ATTEMPTS;
shouldRetryFileOperation(error) && attempt + 1 < BACKUP_COPY_MAX_ATTEMPTS;
if (canRetry) {
await new Promise((resolve) =>
setTimeout(resolve, BACKUP_COPY_BASE_DELAY_MS * 2 ** attempt),
Expand All @@ -295,10 +295,8 @@ async function renameFileWithRetry(
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 < BACKUP_COPY_MAX_ATTEMPTS;
shouldRetryFileOperation(error) && attempt + 1 < BACKUP_COPY_MAX_ATTEMPTS;
if (!canRetry) {
throw error;
}
Expand Down Expand Up @@ -1834,13 +1832,20 @@ async function saveAccountsUnlocked(storage: AccountStorageV3): Promise<void> {
checksum: computeSha256(content),
content,
};
await fs.writeFile(walPath, JSON.stringify(journalEntry), {
encoding: "utf-8",
mode: 0o600,
});
// Secret-bearing WAL write: retry transient Windows locks via the shared
// taxonomy so a momentary AV/indexer/concurrent-reader lock can't fail an
// otherwise-valid save (EBUSY/EPERM/EAGAIN/ENOTEMPTY/EACCES).
await withFileOperationRetry(() =>
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 }),
withFileOperationRetry(() =>
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;
Expand Down Expand Up @@ -1875,8 +1880,11 @@ async function saveAccountsUnlocked(storage: AccountStorageV3): Promise<void> {
}
},
cleanupTemp: async (tempPath: string) => {
// The temp file holds the full account store (refresh tokens, 0o600).
// Retry cleanup so a transient lock can't strand a secret-bearing *.tmp
// next to the destination; swallow a persistent failure (best effort).
try {
await fs.unlink(tempPath);
await withFileOperationRetry(() => fs.unlink(tempPath));
} catch {
// Ignore cleanup failure.
}
Expand Down
Loading