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