Skip to content

Releases: ndycode/codex-multi-auth

v2.6.1

Choose a tag to compare

@ndycode ndycode released this 14 Jul 09:57

A patch release fixing OAuth login on WSL. Closes #630.

Installing codex-multi-auth on a Windows host and inside WSL at the same time broke sign-in in both environments — and removing the Windows install was the only thing that made WSL work.

Routing, rotation, storage, the account pool, and the token flow are unchanged. Every behavior change is gated behind a new WSL check that is false on all other hosts, so native Windows, macOS, and Linux are unaffected. No state, config, or on-disk layout changes — nothing to migrate.

npm i -g codex-multi-auth

What was broken

The browser never opened. WSL reports process.platform === "linux", so the launcher fell through to xdg-open — which isn't installed on a stock WSL Debian. It returned false silently, no browser appeared, and sign-in looked like a hang.

The callback was silently stolen. The OAuth redirect URI is registered with the provider as http://localhost:1455/auth/callback, so the callback port is fixed and cannot be renegotiated when contended. A browser launched from WSL still runs on the Windows host, and Windows resolves localhost:1455 against its own loopback first — so anything on the Windows side holding that port (an in-progress login, a leftover callback server, a running proxy) receives the redirect the WSL listener is waiting for. From inside the distro this is invisible: the listener binds cleanly and simply never sees the callback.

Fixed

  • WSL logins now open the Windows browser — via wslview (from wslu), falling back to powershell.exe interop, and only then to the Linux opener.
  • The manual-paste clipboard now targets Windows under WSL, routing through clip.exe and then PowerShell Set-Clipboard before the Linux clipboard tools. That fallback is exactly where WSL users were landing.
  • A contended callback port is now named and explained instead of silently degrading to manual paste. login shows how to find the listener on each side of the boundary and points at login --device-auth, which needs no callback port at all.
  • Clipboard spawns handle error on child.stdin — a child that dies before draining stdin emits EPIPE on the stream, which the surrounding try/catch cannot catch. This also hardens the pre-existing pbcopy / xclip / xsel paths.

Contention is only asserted when it is actually observed (EADDRINUSE). A callback that never arrives is far more often a cancelled or abandoned sign-in than a stolen redirect, so that case leads with the likelier explanation and offers the contention diagnosis conditionally. Inspection commands are platform-correct (lsof on macOS, ss on Linux, Get-NetTCPConnection on Windows; both sides inside WSL).

Added

  • A Windows and WSL side by side troubleshooting section: why the port is fixed, how to find the listener on each side, and the --device-auth escape hatch.
  • Documented that Windows and WSL keep separate state directories — accounts are not shared between them, and each environment must be signed in independently.

Notes

  • The callback port cannot be changed. It is baked into the provider-registered redirect URI, which is why this release is detect and explain rather than avoid. codex-multi-auth login --device-auth remains the only way to sign in on both Windows and WSL without coordinating the port.
  • A WSL login that loses the callback still waits out the five-minute callback poll before printing the guidance. Emitting the hint early, while continuing to wait, is a candidate follow-up.
  • 31 new tests covering WSL detection, opener fallback ordering, PowerShell metacharacter escaping, clipboard routing, and the real EADDRINUSE bind path.

Full notes: docs/releases/v2.6.1.md · Full Changelog: v2.6.0...v2.6.1

v2.6.0

Choose a tag to compare

@ndycode ndycode released this 11 Jul 01:10

Follows up the 2.5.0 GPT-5.6 launch: the diagnostic live/quota probe now leads with GPT-5.6, the Codex CLI and VS Code model pickers can surface the GPT-5.6 tiers, and parallel-agent fan-out spreads across accounts by default. Closes #626, #627, #628.

Routing, rotation, storage, and the auth flow are unchanged. DEFAULT_MODEL (the general routing default and the gpt-5 alias target) stays on gpt-5.5, so this is not a breaking change. The one behavior change to note: pidOffsetEnabled now defaults on.

Diagnostics — probe leads with GPT-5.6 (#627)

  • check, report, forecast, best, and fix now probe GPT-5.6. A dedicated DEFAULT_PROBE_MODEL (gpt-5.6-sol) drives the probe and QUOTA_PROBE_MODEL_CHAIN (gpt-5.6-solgpt-5.5gpt-5.4 → codex), so check reports Model probe: gpt-5.6-sol and report --json reports model: gpt-5.6-sol instead of gpt-5.5. Accounts without GPT-5.6 entitlement fall through the chain.
  • DEFAULT_PROBE_MODEL is deliberately separate from DEFAULT_MODEL: only the diagnostic probe moved to 5.6; routing and the gpt-5 alias stay on gpt-5.5.
  • The probe body no longer hardcodes reasoning.effort: "none" — it resolves the cheapest effort each model actually declares (low for the 5.6 tiers and codex models), matching normal request routing.

Model pickers — GPT-5.6 now reachable (#626)

  • Legacy config template (config/codex-legacy.json) gained the GPT-5.6 tiers in the flattened per-effort format — Sol/Terra lowultra, Luna lowmax.
  • Upgrades no longer miss new models. The config installer merged provider.openai shallowly, so an existing config's model list shadowed the template wholesale. It now merges the models map at the model-id level: new template models (the GPT-5.6 tiers) appear on upgrade while your per-id customizations and provider options are preserved.
  • The codex-multi-auth-codex wrapper re-implements the model map (it runs before the TypeScript build) and never got the 2.5.0 GPT-5.6 work — so gpt-5.6-* through the wrapper mis-bucketed its family, mis-coerced effort, and canonicalized to gpt-5.5. It now mirrors the library: max/ultra fallbacks, the Sol/Terra/Luna tiers, effort aliases (none/minimal excluded; ultra Sol/Terra only), the ultramax wire rewrite, the gpt-5.2 prompt family, and a dedicated resolver so unknown 5.6 ids never fall back to gpt-5.5.

Rotation — parallel-agent fan-out on by default (#628)

  • pidOffsetEnabled now defaults true. It gives each codex-multi-auth-codex process a small deterministic account-selection bias so parallel agents spread across accounts instead of all selecting the same one and cascading into 429s. No-op for single-account pools; a manual pin and health/quota scoring still take precedence. Override with pidOffsetEnabled: false or CODEX_AUTH_PID_OFFSET_ENABLED=0.

Docs

  • Corrected two documented defaults that did not match the code: retryAllAccountsRateLimited (false, not true) and retryAllAccountsMaxRetries (0, not Infinity).
  • Added a "High parallelism / swarms of agents" playbook (pidOffsetEnabled, the retryAllAccounts* trio, routingMutex in-process caveat, "more accounts ⇒ less contention").
  • Clarified that a Provider response headers timed out after 10000ms error comes from the host client's own provider header timeout (~10s), not this plugin, whose fetchTimeoutMs defaults to 60000.

Testing

  • Added a wrapper ↔ library parity test across a model × effort matrix so the wrapper's duplicated model map cannot silently drift again, plus an installer upgrade-merge regression test.
  • Verified end-to-end against a live ChatGPT (Pro) account: check --live reports Model probe: gpt-5.6-sol with live quota; the real installer adds the 5.6 tiers to a pre-5.6 config while preserving user models.

Notes

  • Minor release, latest dist-tag: npm i -g codex-multi-auth.
  • Model pickers build from the installed Codex config, so an existing install must re-run its config install (and restart the app/extension) to surface the GPT-5.6 tiers; code-level resolution already handled gpt-5.6-* requests.

Full notes: docs/releases/v2.6.0.md

v2.5.0

Choose a tag to compare

@ndycode ndycode released this 09 Jul 23:48

Adds the GPT-5.6 model family (Sol, Terra, Luna) and the two reasoning tiers it introduces above xhigh. Additive feature release — GPT-5.6 is opt-in, and the legacy gpt-5 alias, the default model, the quota-probe chain, routing, rotation, storage, and the auth flow are unchanged.

Model facts come from the upstream Codex catalog (openai/codexcodex-rs/models-manager/models.json) and codex-rs/core/src/client.rs, not the published API docs — those contradict each other on whether a max tier exists, and quote a context window for the API surface rather than the ChatGPT Codex backend.

Added

  • gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna as first-class models. Bare gpt-5.6 resolves to Sol, matching OpenAI's flagship alias.
  • Reasoning tiers max (all three) and ultra (Sol/Terra only).
  • Per-tier defaults mirror the catalog exactly: Sol low, Terra and Luna medium.
  • Shipped in config/codex-modern.json with a 372,000-token context window.
  • Cost estimation: Sol $5/$30, Terra $2.50/$15, Luna $1/$6 per 1M input/output tokens.

ultra never reaches the wire

ultra denotes Codex's client-side automatic subagent delegation, not a backend effort level. Upstream reasoning_effort_for_request rewrites UltraMax unconditionally before sending:

fn reasoning_effort_for_request(effort: ReasoningEffortConfig) -> ReasoningEffortConfig {
    match effort {
        ReasoningEffortConfig::Ultra => ReasoningEffortConfig::Max,
        effort => effort,
    }
}

This wrapper mirrors that: ultra stays selectable and is emitted as max. WireReasoningEffort excludes it, so it cannot be reintroduced on the request path without a type error.

Fixed

  • Codex Max is no longer rerouted. gpt-5.1-codex-max is a model id that ends in -max, not Codex at max effort. Adding max to the effort-suffix pattern would have stripped it and resolved every Codex Max request onto gpt-5.1-codex. A lookbehind now scopes the guard to the max alternative only, so gpt-5-codex-low still parses low.
  • Unrecognised gpt-5.6-* ids no longer silently resolve to gpt-5.5. The general GPT-5 resolver has no minor 6, so a gpt-5.6-sol request ran GPT-5.5 instead. A dedicated 5.6 resolver now claims them.
  • No GPT-5.6 tier accepts none or minimal. Those are coerced up to a supported effort rather than sent and rejected.
  • .codex-plugin/plugin.json had drifted to 2.3.3 since the 2.4.0 bump; realigned to the package version.

Internals

  • The effort union and the model-id suffix pattern are both derived from one ordered REASONING_EFFORTS list in the leaf lib/constants.ts, so a future tier cannot be added to one and forgotten in the other.
  • ModelReasoningEffort/WireReasoningEffort moved out of lib/request/helpers/model-map.ts into lib/constants.ts, keeping the base types layer out of the request layer. Defining them in lib/types.ts would have closed an import cycle (typesschemasmodel-map). Model-map re-exports both, so no caller changed.

Notes

  • Minor release published under the latest dist-tag (npm i -g codex-multi-auth).
  • GPT-5.6 has no unsupported-model fallback chain into gpt-5.5. An account without 5.6 access fails the request rather than degrading.
  • GPT-5.6 routes to the gpt-5.2 prompt family: upstream ships no gpt_5_6_prompt.md (5.6 base instructions live inline in the model catalog), and gpt-5.5 already reuses that family the same way.

Full notes: docs/releases/v2.5.0.md

v2.4.0

Choose a tag to compare

@ndycode ndycode released this 09 Jul 11:40

Adds per-invocation account forcing for the codex-multi-auth-codex wrapper. Additive feature release — routing, rotation, storage, and the normal auth flow are unchanged when the new flag/env var are unused.

Added

  • codex-multi-auth-codex --account <index|email|id> (and CODEX_MULTI_AUTH_FORCE_ACCOUNT) forces a single forwarded Codex invocation onto one account. Flag wins over the env var.
  • The pin is ephemeral (applied only by that run's rotation-proxy instance, never mutates the persisted switch pin, leak-safe across concurrent runs) and fail-hard (never rotates; errors instead of spilling onto another account when the target is unavailable, the selector doesn't resolve, or the runtime rotation proxy is disabled).

Full notes: docs/releases/v2.4.0.md · Issue #623 · PR #624

Install: npm i -g codex-multi-auth

v2.3.3

Choose a tag to compare

@ndycode ndycode released this 19 Jun 10:03
826dc07

Patch release: security and durability hardening from a deep stress audit of the rotation, persistence, and SSE failure-handling paths (#617, #618, #619). No feature changes; routing, account-selection, and the normal auth flow are unchanged.

Install: npm i -g codex-multi-auth


Runtime Rotation / Recovery

Bugfixes

  • Fixed an unbounded rate-limit window that could wedge an account unavailable for years. A 429 (or a 2xx carrying x-codex-*-reset-* headers) was honored using the upstream retry-after/reset value with no upper clamp, and the setter only lower-clamped while growing resetAt monotonically — so a single hostile or buggy response (a seconds-vs-milliseconds confusion, an anti-abuse misfire, a retry-after-ms of 999999999999) marked the account rate-limited for ~31 years, persisted that to disk, and never self-healed. Because the lockout is per-account the pool was never fully exhausted, so the stale-recovery guard never fired either. Retry and quota windows are now clamped to MAX_RATE_LIMIT_DELAY_MS (7 days), applied centrally in markRateLimitedWithReason and again at source in getQuotaNearExhaustionWaitMs, matching the clamp the legacy reactive fetch path already enforced (#617).
  • Fixed the refresh lease deleting a lock it no longer owned. The lease wrote a pid/acquiredAt payload but never read it back at release, and release() called safeUnlink(lockPath) unconditionally. If an owner's refresh ran about as long as the lease TTL, its lease expired, a second process stole the lock, and the slow owner then deleted the new owner's lock on completion — leaving two concurrent refreshers. Because the OAuth refresh token rotates per refresh, the losing process could submit an already-consumed token and log the account out until re-login. The lock now carries a per-owner nonce and release() only unlinks when the on-disk nonce still matches; a lock written before this change (no nonce) keeps the prior best-effort behavior. This is scoped to deployments running more than one CLI/proxy instance against a shared auth directory (#617).
  • Fixed a cross-process clobber of freshly-rotated refresh tokens. saveToDisk discarded the disk-loaded state and re-serialized the entire in-memory pool, so when a second process refreshed an account and wrote a rotated single-use token, a routine save from a long-lived proxy (cooldown, rate-limit, near-quota refund) could last-writer-win and revert it — permanently breaking that account's next refresh. The save now reconciles per-account token material from disk under the storage lock, adopting a strictly-newer on-disk token; the refresh-commit path still wins with its own fresher token (#617).

Quota / Forecast

Bugfixes

  • Fixed a transient 429 benching an account for the full deferral cap. markRateLimited folded the existing weekly secondary reset (normally ~7 days out on a healthy 200 snapshot) into the primary window via max(...), so a blip with a 30–60s Retry-After produced a multi-hour deferral and the account was rotated away for the full 2h cap. The 429 path now treats only genuinely-exhausted windows (used ≥ 100%, or a window with no usage gauge) as rate-limit windows, in both markRateLimited and getDeferral; the documented "longest active reset window" behavior for real rate-limit windows is unchanged (#617).
  • Fixed the live-quota forecast overstating the wait and inverting the recommendation. getLiveQuotaWaitMs took a blind max of both quota windows, so a healthy weekly secondary (~7 days) dominated a binding 5h primary that frees in seconds, and recommendForecastAccount — which sorts ascending by wait — then preferred a strictly-worse account and displayed a wait wrong by orders of magnitude. Under usage pressure it now filters to exhausted windows, mirroring the quota-cache path; a 429 still honors every active window (#617).

Request / SSE Data Path

Bugfixes

  • Fixed upstream SSE failures being reported to the client as success. A mid-stream {"type":"error"} event, or a terminal response.failed event, left convertSseToJson returning the raw SSE text at HTTP 200; downstream this ran the success path, the empty-response guard's JSON.parse threw on the SSE body and was swallowed, and the account was recorded as a success with rotation and retry suppressed. A stream that opens 200 but ends without a successful final response now resolves to a synthesized non-2xx so the caller routes to failure, while a stream that simply yields no events without an error is still passed through so the empty-response retry path is preserved (#617, #618).
  • Fixed the SSE parser requiring a trailing space after data:. A spec-valid data:value line (no space) parsed as zero events, silently degrading the response to "no final response" on any upstream or proxy formatting change. The parser now accepts data: with optional whitespace (#617).

Behavior

  • response.incomplete (hitting max_output_tokens or a content filter) is treated as a normal early stop, not a failure. It carries a final response object whose partial output is the answer, so it is delivered at HTTP 200 and counts as a healthy account — distinct from the response.failed path above, which routes to failure (#618).

Storage / Auth / Logging

Bugfixes

  • Fixed the V1→V3 storage migration discarding the migrated account bodies. normalizeAccountStorage rebuilt the account list from the raw V1 objects rather than the migration output, dropping migrateV1ToV3's scalar rateLimitResetTime → map rateLimitResetTimes conversion — so a rate-limited account upgrading from V1 was read with no reset times, treated as immediately available, and could burst 429s. The account list is now built from the migrated storage (#619).
  • Fixed a durability gap in the local-client-token store. The store wrote a temp file and renamed it with no fsync in between, so a crash or power-loss after the rename could leave it truncated. The temp file is now flushed with fsync before the rename, matching the durable-write pattern already used by the app-bind and first-run writers (#619).
  • Fixed OAuth expires_in (and the internal expires) accepting any number. A zero or negative value minted an already-expired token, which drove a tight refresh loop that consumed the single-use refresh token; the value now must be a positive integer or it fails schema validation (#619).
  • Hardened the free-text log scrubber to mask this package's own local bearer tokens (cma_local_…) alongside the existing JWT, long-hex, sk-, and Bearer patterns. Structured logging already masks by key and the OAuth path is scrubbed separately; this closes the last-line-of-defense gap for the project's own token shape (#619).

Testing

Improvements

  • Added regression coverage for every fix: the 7-day retry-after clamp (including self-heal once the window elapses) and the at-source quota clamp; the lease ownership nonce, proving a slow owner does not delete a stolen lock; the cross-process token-clobber reconciliation; the transient-429 deferral bound alongside the preserved "longest active reset window" semantics; the forecast exhausted-window filter and the no-longer-inverted recommendation; SSE error/response.failed → non-2xx, response.incomplete200 with partial output, and data: parsing without a trailing space; the V1→V3 rateLimitResetTimes preservation; the OAuth expires_in positive-integer bound; and the cma_local log-masking pattern.
  • Updated four existing tests that asserted the prior behavior (SSE error events returning a raw HTTP 200) to assert the corrected failure routing.

Notes

  • Patch release published under the latest dist-tag (npm i -g codex-multi-auth).
  • No runtime-rotation routing, account-selection, storage layout, or normal auth-flow behavior changed; the fixes harden failure, concurrency, and edge-case paths.
  • The multi-process fixes (lease ownership, token reconciliation) matter most when more than one CLI/proxy instance shares an auth directory; single-process usage is unaffected by those races.

v2.3.2

Choose a tag to compare

@ndycode ndycode released this 16 Jun 14:55

Patch release: self-healing recovery for an orphaned runtime-proxy app-bind (#614, #615). No runtime-rotation, storage, or auth behavior changed.

Install: npm i -g codex-multi-auth


Runtime Rotation / App Bind

Bugfixes

  • Fixed an orphaned app-bind state that could leave ~/.codex/config.toml pointing at the codex-multi-auth-runtime-proxy provider with no way to recover via the CLI. When app-bind rewrote the config but its state/backup files were later lost (cleanup, partial unbind, a marker-less re-run of first-run setup, or a crash), the config stayed bound while getAppBindStatus and rotation unbind-app — which inferred "bound" purely from the app-bind state files — reported "not configured" and refused to act. The official Codex CLI/Desktop was then routed to a dead proxy port with no automated fix. unbindCodexAppRuntimeRotation now self-heals: when there is no backup and no state but config.toml is still bound, it strips the proxy provider block, restores the top-level model_provider (falling back to openai when no original backup exists), and reports the recovery. getAppBindStatus now derives bound from the config when no state file is present and exposes an unmanagedBind flag, and rotation status surfaces "bound but unmanaged" with the unbind-app remedy instead of "not configured" (#614).
  • Fixed a duplicate model_provider key in the no-backup recovery path. A half-orphaned config (proxy block present but the top-level model_provider already pointing at a real provider) would have a second model_provider line spliced in during restore, producing invalid TOML that Codex refuses to parse. The shared restore now only inserts the original line when no top-level model_provider exists; an existing line is left untouched.

Testing

Improvements

  • Added regression coverage for the recovery path: detection of a bound config from either the top-level provider or the proxy block (including stray-mention and empty-config cases); no-backup restore across full-orphan, half-orphan, block-only, custom-default-provider, and CRLF variants; disable_response_storage cleanup; unmanagedBind status detection; and integration-level self-heal through unbindCodexAppRuntimeRotation for both the full-orphan and half-orphan cases.

Notes

  • Patch release published under the latest dist-tag (npm i -g codex-multi-auth).
  • No runtime-rotation routing, account-selection, storage, or auth behavior changed; the normal backed-up bind/unbind path is unchanged.
  • If you previously hit the orphaned-bind state, run codex-multi-auth rotation unbind-app to restore your config (it now works even without a saved backup).

v2.3.0

Choose a tag to compare

@ndycode ndycode released this 15 Jun 09:41
65b16e9

Runtime Rotation

Bugfixes

  • Fixed a stale-runtime recovery deadlock that returned a permanent 503 "All managed Codex accounts are temporarily unavailable for this runtime request." even when accounts were healthy and doctor passed. Per-account transient state (coolingDownUntil, cooldownReason, rateLimitResetTimes) is serialized into the V3 snapshot, so recoverStaleRuntimeState's loadFromDisk() restored the same state that had wedged the pool, while the recovery guard refused to run whenever any account's skip reason was "rate-limited" or "cooling-down*". The guard now suppresses recovery only on "policy-blocked" (external, unchanged by a reload), and recoverStaleRuntimeState calls AccountManager.clearAccountTransientState() then flushPendingSave() before publishing the reloaded manager, so the cleared snapshot survives a restart within the debounce window. The two halves are coupled — each is pinned by a regression test that fails if the other is reverted (#606, #607).
  • Fixed the missing-accountId cooldown branch in runRotationLoop not persisting its mutation. When resolveAccountId returned null the branch called markAccountCoolingDown but — unlike every sibling cooldown branch (network-error, 429, server-error, 401 invalidation) — never called saveToDiskDebounced(), so a restart inside the 30s window dropped the cooldown and immediately re-selected the still-broken account. Added the missing persist (#608).
  • Fixed the short-retry 429 path in the runtime fetch loop not persisting its rate-limit window. The branch mutated the disk-serialized rateLimitResetTimes via markRateLimitedWithReason, then slept and retried without a saveToDiskDebounced(), unlike the full-rotation branch beside it; a crash during the retry sleep lost the reset time. Added the missing persist (#609).

Testing

Improvements

  • Added regression coverage for all three durability fixes: clearAccountTransientState unit tests (cooldown clear, rate-limit clear incl. future windows, mixed state, no-op on empty pool, flush-backed persistence); all-cooling-down pool recovers to 200 while policy-blocked pools still suppress recovery; missing-accountId and short-retry branches each assert saveToDiskDebounced is scheduled. Each fix's test fails if its source change is reverted (verified by mutation).

Notes

  • Stable release published under the latest dist-tag (npm i -g codex-multi-auth).
  • All three fixes share one root-cause class: transient account state is persisted to disk, so any path that mutates it must schedule a write or a restart silently drops it. All mutation sites were audited; these were the gaps.
  • The codex-multi-auth rotation reset-rate-limits command remains available as a manual escape hatch.
  • Promotes the 2.3.0-beta line to stable; all fixes from 2.3.0-beta.12.3.0-beta.3 are included.

v2.3.0-beta.3

v2.3.0-beta.3 Pre-release
Pre-release

Choose a tag to compare

@ndycode ndycode released this 11 Jun 08:17

Runtime Rotation

Bugfixes

  • Fixed stream forwarding stalling indefinitely for slow clients. forwardStreamingResponse now checks the return value of res.write() and awaits drain before reading the next upstream chunk, preventing unbounded in-process buffering when the client socket falls behind.

Improvements

  • Converted the two startup guards in startRuntimeRotationProxy from bare Error throws to CodexValidationError with machine-readable field/expected/context metadata. Error messages are byte-identical; callers can now branch on instanceof CodexValidationError and stable field names instead of message text (audit §4.3, #586).

Storage

Bugfixes

  • Fixed multi-tier account deduplication. deduplicateAccountsByIdentity now runs fixpoint iteration: a single pass was not enough when a newest-wins merge could install an account that itself duplicated an earlier survivor through a different identity tier (e.g. an email-tier merge installs an account whose accountId + refreshToken already matches an earlier entry). The wrapper now loops until the array is stable; every pass strictly shrinks it by at least one entry, so it terminates in at most accounts.length passes.
  • Added vi.restoreAllMocks() to storage.test.ts afterEach to prevent a failing test's leaked fs spy from cascading into every subsequent storage test in the same worker.

Improvements

  • Migrated the last two hand-rolled retry loops to the shared withRetry helper in lib/fs-retry.ts: the temp→final account-save rename (storage.ts) and the config env-path CAS loop (config.ts). Inter-attempt delay schedules are unchanged; only the wasted trailing sleep after a final failure is removed.
  • Converted savePluginConfig's "unreadable config file" abort from a bare Error to a typed StorageError with code: "UNREADABLE" and the file path. Callers can now branch on instanceof StorageError instead of message text (#588, audit §4.3).

Security

Improvements

  • All atomic write helpers now use crypto.randomBytes instead of Math.random() for staging-path nonces, preventing a local attacker from predicting the next staging path (#517).

Code Quality

Improvements

  • Removed 852 lines of dead code: seven orphaned modules with no live importers deleted (#554, #558).
  • Pruned unused exports and types flagged by knip; added knip.jsonc config for ongoing dead-code analysis (#555, #556, #557).
  • isRetryableStorageWriteError, copyDashboardSettingValue, mergeDashboardSettingsForKeys, and DEFAULT_STATUSLINE_FIELDS exported from their respective modules for direct test access.
  • Synced plugin manifest and AGENTS.md package-version claim to v2.3.0-beta.3.

Testing

Improvements

  • 20 new direct test suites covering: login-oauth, login-menu actions/flow/data, persist-selected, health-check, forecast-report-shared, settings write-queue, rotation selection/state/token-refresh, auth-menu builder, model-fallback property, write-queue property, rate-limit helpers, usage-ledger redaction, settings preview builders, and settings-hub shared helpers.
  • Property-based test suite for deduplicateAccountsByIdentity: covers order-independence and convergence across all permutations using fast-check.
  • shouldRetryFileOperation, fs-retry, and temp-path covered with new unit suites.

Notes

  • Prerelease published under the beta dist-tag (npm i -g codex-multi-auth@beta).
  • The #509 sequential drain-first feature and all fixes from 2.3.0-beta.2 are included.

v2.3.0-beta.2

v2.3.0-beta.2 Pre-release
Pre-release

Choose a tag to compare

@ndycode ndycode released this 10 Jun 17:43

Runtime Rotation

Bugfixes

  • Fixed the sequential drain-first pointer advancing during a within-request fallback in legacy routing mode. persistRuntimeActiveAccount called markSwitchedLocked unconditionally when routingMutex !== "enabled", including when schedulingStrategy: "sequential" was set; a transient token-bucket fallback to a secondary account would permanently move the primary and break the #509 drain-first invariant. The same schedulingStrategy !== "sequential" guard already present in the enabled-mutex branch now applies to the legacy branch too.
  • Fixed ensureFreshAccessToken forwarding an expired access token when commitRefreshedAuthOnce returned null (account not found in storage after persist). The fallback used updatedAccount.access, which was the original stale token, causing a downstream 401 and incorrectly triggering invalidation-cooldown logic. The function now uses refreshResult.access (the just-issued token) on the null-commit path while preserving the committed account's stored token on the success path (required for the dedup-commit case where two concurrent callers share one commit).

Storage

Bugfixes

  • Fixed normalizeFlaggedStorage silently dropping workspaces, currentWorkspaceIndex, accessToken, and expiresAt from flagged accounts. The function built a hardcoded field list that omitted these fields, defeating the Zod schema's .passthrough() intent; multi-workspace accounts permanently lost their workspace list and active-workspace index after a flag→restore round-trip. All four fields are now preserved.
  • Fixed refreshQuotaCacheForMenu wiping all other accounts' quota data when a transient disk failure occurred during cache reload. The rebase-merge block had a dead catch because loadQuotaCache() never throws — it returns empty maps on any read failure. When the persisted cache came back empty, only this run's probed entries were written back, discarding every other account's still-valid quota data. The empty-load case is now detected explicitly and falls back to the full in-memory snapshot.

Security

Hardening

  • All atomic write helpers (storage.ts, recovery/storage.ts, quota-cache.ts, unified-settings.ts, and twelve other call sites) now use crypto.randomBytes for staging-path nonces instead of Math.random(), preventing a local attacker who can observe one staged path from predicting the next one (#517).
  • All GitHub Actions workflow steps are pinned to exact commit SHAs rather than floating version tags, hardening against supply-chain tag drift (#519).
  • Upstream response headers matching x-codex-multi-auth-account-* are now blocked by prefix match rather than an allowlist; a future header added under that namespace is blocked by default rather than leaking until someone extends a list (#546).
  • Fixed withTimeout rejecting after calling onTimeout: cancelling a stream reader in onTimeout can settle the raced promise with a clean done: true, and a settlement enqueued ahead of the rejection would win the race, turning a stall into a silent success. Reject is now issued before onTimeout (#546).

Refactoring

Improvements

  • Decomposed lib/codex-manager.ts across four phases: login control loop, OAuth machinery, command registry, and formatter modules extracted into lib/codex-manager/ submodules. The file shrinks from 2,266 lines to 690 lines (-82%) (#525, #535, #540, #547).
  • Decomposed lib/runtime-rotation-proxy.ts across two phases: rate-limit decision logic, stream-failover runtime, and rotation-proxy closure state extracted into lib/request/ and lib/runtime/ submodules (#532, #548).
  • Eliminated all detected import cycles; eslint-plugin-import-x no-cycle rule is now enforced in CI so regressions are caught at commit time (#541).
  • Replaced local isRecord guards in runtime-current-account.ts, codex-manager/commands/rotation.ts, and refresh-lease.ts with the canonical lib/utils.ts version. The local copies accepted arrays (typeof v === "object" && v !== null); the canonical version correctly rejects them (#544, #545).

CLI & Tests

Improvements

  • Added shared cli-test-fixtures mock factory infrastructure; all manager test suites now use a consistent set of factories, removing per-suite boilerplate (#537, #539, #550).
  • Pinned the machine-readable JSON output contract (status, report, doctor, forecast, why-selected) with snapshot tests that catch shape regressions (#533).

Build & Packaging

Improvements

  • Stripped JS source maps from the published package; declaration maps are kept for go-to-definition. Reduces published size (#527).
  • Pinned @types/node to the supported runtime floor major to prevent silent type drift from newer Node API additions (#528).
  • Raised engines.node to >=18.17.0, reflecting the actual tested minimum and aligning with undici@6 and the node18-smoke CI job (#518).
  • config/schema/config.schema.json is now generated from the Zod schema with a byte-exact drift-guard test; the schema and its source of truth can never silently diverge (#536).

Notes

  • Prerelease published under the beta dist-tag (npm i -g codex-multi-auth@beta).
  • The #509 sequential drain-first feature from 2.3.0-beta.0 is unchanged; the pointer-corruption bug above is now fixed.

v2.3.0-beta.1

v2.3.0-beta.1 Pre-release
Pre-release

Choose a tag to compare

@ndycode ndycode released this 07 Jun 09:29
d8306d5

Account Login

Bugfixes

  • Stopped codex-multi-auth login from always printing Added account when a
    same-email / different-workspace login folded onto an existing saved entry.
    The CLI login path used local copies of resolveAccountSelection and
    persistAccountPool in lib/codex-manager.ts that had drifted from the
    workspace-aware versions in lib/runtime/ (added for #491 and used only by
    the runtime proxy); the CLI copies never persisted workspaces and
    unconditionally reported Added account. The login flow now reports the real
    outcome — Added account, Updated existing account, or
    Rebound workspace for existing account — based on whether the write
    inserted a new entry, refreshed an existing one, or surfaced a
    previously-untracked workspace (issue #512).
  • Persisted token-derived workspaces on the saved account so
    codex-multi-auth workspace <account> is usable after a same-email
    multi-workspace login. Rows no longer save with workspaces: null;
    per-workspace enabled/disabledAt state is preserved across re-logins, and
    the explicit login --org <id> binding now tracks workspaces too (previously
    the override path returned before workspace discovery).
  • Classified the first workspace-aware re-login of a pre-#491 account (one with
    no tracked workspaces yet) as Updated existing account rather than
    Rebound workspace, so quiet first-time enrichment is not mislabeled as a
    rebind.

Manual sign-in

  • Surfaced real validation errors from codex-multi-auth login --manual
    instead of reporting every failure as Cancelled.. The manual callback
    reader returned null for a genuine user cancel, a callback URL missing the
    code/state parameter, and an OAuth state mismatch alike, and the caller
    treated all three as a cancellation. A new pure classifier
    (classifyManualCallbackInput) distinguishes code / cancelled /
    invalid / state-mismatch; invalid and state-mismatch now exit non-zero
    with a specific, actionable message (callbackInvalid /
    callbackStateMismatch), while a genuine cancellation is unchanged
    (issue #512 follow-up).

Release Hygiene

Tests

  • Extracted the account-pool fold (dedup → insert/update/rebound →
    workspace-tracking → active-index) into pure helpers
    (applyAccountPoolResults, buildInsertedAccount, buildUpdatedAccount,
    mergeAccountWorkspaces, resolveCurrentWorkspaceIndex) and covered them with
    unit tests, including an end-to-end reproduction of the same-email
    multi-workspace scenario driven through the real findMatchingAccountIndex
    dedup strategy.
  • Added CLI regressions: login --org <id> persists workspace tracking, a
    mismatched manual-callback state exits non-zero with the state-mismatch
    message, and a malformed manual-callback URL exits non-zero with the
    callbackInvalid message — none of which persist an account.
  • Full classifier coverage for the manual-callback contract, including the
    reporter's "pasted a localhost callback URL but still saw Cancelled" case.

Notes

  • Prerelease published under the beta dist-tag
    (npm i -g codex-multi-auth@beta). This is a bugfix beta on the 2.3.0 line;
    the #509 sequential drain-first feature from 2.3.0-beta.0 is unchanged.