Releases: ndycode/codex-multi-auth
Release list
v2.6.1
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(fromwslu), falling back topowershell.exeinterop, and only then to the Linux opener. - The manual-paste clipboard now targets Windows under WSL, routing through
clip.exeand then PowerShellSet-Clipboardbefore 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.
loginshows how to find the listener on each side of the boundary and points atlogin --device-auth, which needs no callback port at all. - Clipboard spawns handle
erroronchild.stdin— a child that dies before draining stdin emitsEPIPEon the stream, which the surroundingtry/catchcannot catch. This also hardens the pre-existingpbcopy/xclip/xselpaths.
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-authescape 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-authremains 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
EADDRINUSEbind path.
Full notes: docs/releases/v2.6.1.md · Full Changelog: v2.6.0...v2.6.1
v2.6.0
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, andfixnow probe GPT-5.6. A dedicatedDEFAULT_PROBE_MODEL(gpt-5.6-sol) drives the probe andQUOTA_PROBE_MODEL_CHAIN(gpt-5.6-sol→gpt-5.5→gpt-5.4→ codex), socheckreportsModel probe: gpt-5.6-solandreport --jsonreportsmodel: gpt-5.6-solinstead ofgpt-5.5. Accounts without GPT-5.6 entitlement fall through the chain.DEFAULT_PROBE_MODELis deliberately separate fromDEFAULT_MODEL: only the diagnostic probe moved to 5.6; routing and thegpt-5alias stay ongpt-5.5.- The probe body no longer hardcodes
reasoning.effort: "none"— it resolves the cheapest effort each model actually declares (lowfor 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/Terralow…ultra, Lunalow…max. - Upgrades no longer miss new models. The config installer merged
provider.openaishallowly, so an existing config's model list shadowed the template wholesale. It now merges themodelsmap 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-codexwrapper re-implements the model map (it runs before the TypeScript build) and never got the 2.5.0 GPT-5.6 work — sogpt-5.6-*through the wrapper mis-bucketed its family, mis-coerced effort, and canonicalized togpt-5.5. It now mirrors the library:max/ultrafallbacks, the Sol/Terra/Luna tiers, effort aliases (none/minimalexcluded;ultraSol/Terra only), theultra→maxwire rewrite, thegpt-5.2prompt family, and a dedicated resolver so unknown 5.6 ids never fall back togpt-5.5.
Rotation — parallel-agent fan-out on by default (#628)
pidOffsetEnablednow defaultstrue. It gives eachcodex-multi-auth-codexprocess a small deterministic account-selection bias so parallel agents spread across accounts instead of all selecting the same one and cascading into429s. No-op for single-account pools; a manual pin and health/quota scoring still take precedence. Override withpidOffsetEnabled: falseorCODEX_AUTH_PID_OFFSET_ENABLED=0.
Docs
- Corrected two documented defaults that did not match the code:
retryAllAccountsRateLimited(false, nottrue) andretryAllAccountsMaxRetries(0, notInfinity). - Added a "High parallelism / swarms of agents" playbook (
pidOffsetEnabled, theretryAllAccounts*trio,routingMutexin-process caveat, "more accounts ⇒ less contention"). - Clarified that a
Provider response headers timed out after 10000mserror comes from the host client's own provider header timeout (~10s), not this plugin, whosefetchTimeoutMsdefaults to60000.
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 --livereportsModel probe: gpt-5.6-solwith live quota; the real installer adds the 5.6 tiers to a pre-5.6 config while preserving user models.
Notes
- Minor release,
latestdist-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
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/codex → codex-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-lunaas first-class models. Baregpt-5.6resolves to Sol, matching OpenAI's flagship alias.- Reasoning tiers
max(all three) andultra(Sol/Terra only). - Per-tier defaults mirror the catalog exactly: Sol
low, Terra and Lunamedium. - Shipped in
config/codex-modern.jsonwith a 372,000-token context window. - Cost estimation: Sol
$5/$30, Terra$2.50/$15, Luna$1/$6per 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 Ultra → Max 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-maxis a model id that ends in-max, not Codex atmaxeffort. Addingmaxto the effort-suffix pattern would have stripped it and resolved every Codex Max request ontogpt-5.1-codex. A lookbehind now scopes the guard to themaxalternative only, sogpt-5-codex-lowstill parseslow. - Unrecognised
gpt-5.6-*ids no longer silently resolve togpt-5.5. The general GPT-5 resolver has no minor6, so agpt-5.6-solrequest ran GPT-5.5 instead. A dedicated 5.6 resolver now claims them. - No GPT-5.6 tier accepts
noneorminimal. Those are coerced up to a supported effort rather than sent and rejected. .codex-plugin/plugin.jsonhad drifted to2.3.3since 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_EFFORTSlist in the leaflib/constants.ts, so a future tier cannot be added to one and forgotten in the other. ModelReasoningEffort/WireReasoningEffortmoved out oflib/request/helpers/model-map.tsintolib/constants.ts, keeping the base types layer out of the request layer. Defining them inlib/types.tswould have closed an import cycle (types→schemas→model-map). Model-map re-exports both, so no caller changed.
Notes
- Minor release published under the
latestdist-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.2prompt family: upstream ships nogpt_5_6_prompt.md(5.6 base instructions live inline in the model catalog), andgpt-5.5already reuses that family the same way.
Full notes: docs/releases/v2.5.0.md
v2.4.0
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>(andCODEX_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
switchpin, 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
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 a2xxcarryingx-codex-*-reset-*headers) was honored using the upstreamretry-after/reset value with no upper clamp, and the setter only lower-clamped while growingresetAtmonotonically — so a single hostile or buggy response (a seconds-vs-milliseconds confusion, an anti-abuse misfire, aretry-after-msof999999999999) 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 toMAX_RATE_LIMIT_DELAY_MS(7 days), applied centrally inmarkRateLimitedWithReasonand again at source ingetQuotaNearExhaustionWaitMs, 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/acquiredAtpayload but never read it back at release, andrelease()calledsafeUnlink(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 andrelease()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.
saveToDiskdiscarded 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
429benching an account for the full deferral cap.markRateLimitedfolded the existing weekly secondary reset (normally ~7 days out on a healthy200snapshot) into the primary window viamax(...), so a blip with a 30–60sRetry-Afterproduced a multi-hour deferral and the account was rotated away for the full 2h cap. The429path now treats only genuinely-exhausted windows (used ≥ 100%, or a window with no usage gauge) as rate-limit windows, in bothmarkRateLimitedandgetDeferral; 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.
getLiveQuotaWaitMstook a blindmaxof both quota windows, so a healthy weekly secondary (~7 days) dominated a binding 5h primary that frees in seconds, andrecommendForecastAccount— 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; a429still 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 terminalresponse.failedevent, leftconvertSseToJsonreturning the raw SSE text at HTTP200; downstream this ran the success path, the empty-response guard'sJSON.parsethrew on the SSE body and was swallowed, and the account was recorded as a success with rotation and retry suppressed. A stream that opens200but ends without a successful final response now resolves to a synthesized non-2xxso 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-validdata:valueline (no space) parsed as zero events, silently degrading the response to "no final response" on any upstream or proxy formatting change. The parser now acceptsdata:with optional whitespace (#617).
Behavior
response.incomplete(hittingmax_output_tokensor 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 HTTP200and counts as a healthy account — distinct from theresponse.failedpath above, which routes to failure (#618).
Storage / Auth / Logging
Bugfixes
- Fixed the V1→V3 storage migration discarding the migrated account bodies.
normalizeAccountStoragerebuilt the account list from the raw V1 objects rather than the migration output, droppingmigrateV1ToV3's scalarrateLimitResetTime→ maprateLimitResetTimesconversion — so a rate-limited account upgrading from V1 was read with no reset times, treated as immediately available, and could burst429s. 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
fsyncin between, so a crash or power-loss after the rename could leave it truncated. The temp file is now flushed withfsyncbefore the rename, matching the durable-write pattern already used by the app-bind and first-run writers (#619). - Fixed OAuth
expires_in(and the internalexpires) 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-, andBearerpatterns. 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-
429deferral bound alongside the preserved "longest active reset window" semantics; the forecast exhausted-window filter and the no-longer-inverted recommendation; SSEerror/response.failed→ non-2xx,response.incomplete→200with partial output, anddata:parsing without a trailing space; the V1→V3rateLimitResetTimespreservation; the OAuthexpires_inpositive-integer bound; and thecma_locallog-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
latestdist-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
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.tomlpointing at thecodex-multi-auth-runtime-proxyprovider 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 whilegetAppBindStatusandrotation 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.unbindCodexAppRuntimeRotationnow self-heals: when there is no backup and no state butconfig.tomlis still bound, it strips the proxy provider block, restores the top-levelmodel_provider(falling back toopenaiwhen no original backup exists), and reports the recovery.getAppBindStatusnow derivesboundfrom the config when no state file is present and exposes anunmanagedBindflag, androtation statussurfaces "bound but unmanaged" with theunbind-appremedy instead of "not configured" (#614). - Fixed a duplicate
model_providerkey in the no-backup recovery path. A half-orphaned config (proxy block present but the top-levelmodel_provideralready pointing at a real provider) would have a secondmodel_providerline spliced in during restore, producing invalid TOML that Codex refuses to parse. The shared restore now only inserts the original line when no top-levelmodel_providerexists; 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_storagecleanup;unmanagedBindstatus detection; and integration-level self-heal throughunbindCodexAppRuntimeRotationfor both the full-orphan and half-orphan cases.
Notes
- Patch release published under the
latestdist-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-appto restore your config (it now works even without a saved backup).
v2.3.0
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 anddoctorpassed. Per-account transient state (coolingDownUntil,cooldownReason,rateLimitResetTimes) is serialized into the V3 snapshot, sorecoverStaleRuntimeState'sloadFromDisk()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), andrecoverStaleRuntimeStatecallsAccountManager.clearAccountTransientState()thenflushPendingSave()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-
accountIdcooldown branch inrunRotationLoopnot persisting its mutation. WhenresolveAccountIdreturned null the branch calledmarkAccountCoolingDownbut — unlike every sibling cooldown branch (network-error, 429, server-error, 401 invalidation) — never calledsaveToDiskDebounced(), 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
rateLimitResetTimesviamarkRateLimitedWithReason, then slept and retried without asaveToDiskDebounced(), 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:
clearAccountTransientStateunit tests (cooldown clear, rate-limit clear incl. future windows, mixed state, no-op on empty pool, flush-backed persistence); all-cooling-down pool recovers to200while policy-blocked pools still suppress recovery; missing-accountIdand short-retry branches each assertsaveToDiskDebouncedis scheduled. Each fix's test fails if its source change is reverted (verified by mutation).
Notes
- Stable release published under the
latestdist-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-limitscommand remains available as a manual escape hatch. - Promotes the
2.3.0-betaline to stable; all fixes from2.3.0-beta.1→2.3.0-beta.3are included.
v2.3.0-beta.3
Runtime Rotation
Bugfixes
- Fixed stream forwarding stalling indefinitely for slow clients.
forwardStreamingResponsenow checks the return value ofres.write()and awaitsdrainbefore reading the next upstream chunk, preventing unbounded in-process buffering when the client socket falls behind.
Improvements
- Converted the two startup guards in
startRuntimeRotationProxyfrom bareErrorthrows toCodexValidationErrorwith machine-readablefield/expected/contextmetadata. Error messages are byte-identical; callers can now branch oninstanceof CodexValidationErrorand stable field names instead of message text (audit §4.3, #586).
Storage
Bugfixes
- Fixed multi-tier account deduplication.
deduplicateAccountsByIdentitynow 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 whoseaccountId + refreshTokenalready 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 mostaccounts.lengthpasses. - Added
vi.restoreAllMocks()tostorage.test.tsafterEachto prevent a failing test's leakedfsspy from cascading into every subsequent storage test in the same worker.
Improvements
- Migrated the last two hand-rolled retry loops to the shared
withRetryhelper inlib/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 bareErrorto a typedStorageErrorwithcode: "UNREADABLE"and the file path. Callers can now branch oninstanceof StorageErrorinstead of message text (#588, audit §4.3).
Security
Improvements
- All atomic write helpers now use
crypto.randomBytesinstead ofMath.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.jsoncconfig for ongoing dead-code analysis (#555, #556, #557). isRetryableStorageWriteError,copyDashboardSettingValue,mergeDashboardSettingsForKeys, andDEFAULT_STATUSLINE_FIELDSexported from their respective modules for direct test access.- Synced plugin manifest and
AGENTS.mdpackage-version claim tov2.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, andtemp-pathcovered with new unit suites.
Notes
- Prerelease published under the
betadist-tag (npm i -g codex-multi-auth@beta). - The #509 sequential drain-first feature and all fixes from
2.3.0-beta.2are included.
v2.3.0-beta.2
Runtime Rotation
Bugfixes
- Fixed the sequential drain-first pointer advancing during a within-request fallback in legacy routing mode.
persistRuntimeActiveAccountcalledmarkSwitchedLockedunconditionally whenroutingMutex !== "enabled", including whenschedulingStrategy: "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 sameschedulingStrategy !== "sequential"guard already present in the enabled-mutex branch now applies to the legacy branch too. - Fixed
ensureFreshAccessTokenforwarding an expired access token whencommitRefreshedAuthOncereturnednull(account not found in storage after persist). The fallback usedupdatedAccount.access, which was the original stale token, causing a downstream 401 and incorrectly triggering invalidation-cooldown logic. The function now usesrefreshResult.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
normalizeFlaggedStoragesilently droppingworkspaces,currentWorkspaceIndex,accessToken, andexpiresAtfrom 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
refreshQuotaCacheForMenuwiping all other accounts' quota data when a transient disk failure occurred during cache reload. The rebase-merge block had a deadcatchbecauseloadQuotaCache()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 usecrypto.randomBytesfor staging-path nonces instead ofMath.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
withTimeoutrejecting after callingonTimeout: cancelling a stream reader inonTimeoutcan settle the raced promise with a cleandone: true, and a settlement enqueued ahead of the rejection would win the race, turning a stall into a silent success. Reject is now issued beforeonTimeout(#546).
Refactoring
Improvements
- Decomposed
lib/codex-manager.tsacross four phases: login control loop, OAuth machinery, command registry, and formatter modules extracted intolib/codex-manager/submodules. The file shrinks from 2,266 lines to 690 lines (-82%) (#525, #535, #540, #547). - Decomposed
lib/runtime-rotation-proxy.tsacross two phases: rate-limit decision logic, stream-failover runtime, and rotation-proxy closure state extracted intolib/request/andlib/runtime/submodules (#532, #548). - Eliminated all detected import cycles;
eslint-plugin-import-xno-cycle rule is now enforced in CI so regressions are caught at commit time (#541). - Replaced local
isRecordguards inruntime-current-account.ts,codex-manager/commands/rotation.ts, andrefresh-lease.tswith the canonicallib/utils.tsversion. 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-fixturesmock 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/nodeto the supported runtime floor major to prevent silent type drift from newer Node API additions (#528). - Raised
engines.nodeto>=18.17.0, reflecting the actual tested minimum and aligning withundici@6and thenode18-smokeCI job (#518). config/schema/config.schema.jsonis 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
betadist-tag (npm i -g codex-multi-auth@beta). - The #509 sequential drain-first feature from
2.3.0-beta.0is unchanged; the pointer-corruption bug above is now fixed.
v2.3.0-beta.1
Account Login
Bugfixes
- Stopped
codex-multi-auth loginfrom always printingAdded accountwhen a
same-email / different-workspace login folded onto an existing saved entry.
The CLI login path used local copies ofresolveAccountSelectionand
persistAccountPoolinlib/codex-manager.tsthat had drifted from the
workspace-aware versions inlib/runtime/(added for #491 and used only by
the runtime proxy); the CLI copies never persistedworkspacesand
unconditionally reportedAdded 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 withworkspaces: null;
per-workspaceenabled/disabledAtstate is preserved across re-logins, and
the explicitlogin --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) asUpdated existing accountrather 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 asCancelled.. The manual callback
reader returnednullfor 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) distinguishescode/cancelled/
invalid/state-mismatch;invalidandstate-mismatchnow 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 realfindMatchingAccountIndex
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
callbackInvalidmessage — 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
betadist-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 from2.3.0-beta.0is unchanged.