fix(runtime): honor manual pin and invalidate session affinity (#474) - #475
Conversation
Issue #474: `codex-multi-auth switch <n>` updated the CLI but the Codex desktop app continued routing through other accounts because the runtime rotation proxy treated the user's manual selection as a no-op. Root cause: the proxy (lib/runtime-rotation-proxy.ts) selected accounts purely via hybrid health/token scoring, never read storage.activeIndex, loaded accounts once at startup, and clobbered the manual selection on every successful request via markSwitched("rotation"). This change introduces an explicit pinned-account contract: * Storage: new optional `pinnedAccountIndex` field on AccountStorageV3, validated in normalizeAccountStorage and threaded through schemas and migrations. Out-of-range or non-finite values are dropped with a warn. * `switch <n>`: now writes the pin (setPin: true) and tags lastSwitchReason as "manual". The reason union grows a "manual" variant in markSwitched, markSwitchedLocked, and persistAndSyncSelectedAccount. * `best`: clears the pin (clearPin: true) and reports it in the success message when a prior pin existed. * New `unpin` subcommand: idempotent clear of the pin, wired into the manager dispatcher and help text. * Proxy: reads pinnedAccountIndex from disk on every request via an mtime-cached helper (readPinnedAccountIndexFromDisk) so a switch from another process is honored without a full AccountManager reload that would lose in-memory cooldown state. chooseAccount honors the pin before session affinity, hybrid scoring, and pool fallback. When the pinned account is unavailable (rate-limited, cooling, disabled, blocked by policy, or out of range), the proxy hard-fails with HTTP 503 and error code `codex_pinned_account_unavailable` rather than silently rotating. persistRuntimeActiveAccount skips markSwitched, saveToDiskDebounced, and syncCodexCliActiveSelectionForIndex when the pinned account is used so the proxy never clobbers the pin it is honoring. * `status`: surfaces the pin and warns when the pin diverges from runtime-in-use. Tests: 20 new behavioral cases in test/issue-474-pin-honored.test.ts covering pin write/clear/unpin, proxy honoring under all unavailable conditions, no-clobber assertion, mtime cache invalidation, and status output. Existing switch-command test updated to assert the new "manual"/setPin contract. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Update docs/reference/commands.md to describe the new pin contract introduced in the issue #474 fix: switch <n> now pins the account for runtime routing, best clears the pin, and unpin clears it explicitly. Also note that a 503 codex_pinned_account_unavailable response from the desktop app indicates the pinned account is rate-limited or otherwise unavailable. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
📝 WalkthroughWalkthroughthe pr adds manual account pinning and an affinity-generation counter, new Changesmanual pin & affinity-based runtime account selection
Sequence DiagramsequenceDiagram
actor user as cli user
participant cli as codex cli
participant disk as disk storage
participant proxy as runtime proxy
participant affinity as session affinity
participant app as desktop app
user->>cli: codex-multi-auth switch 1
cli->>disk: write pinnedAccountIndex=1<br/>read & bump affinityGeneration
disk-->>cli: saved
app->>proxy: request
proxy->>disk: read pinnedAccountIndex & affinityGeneration (mtime-cached)
disk-->>proxy: pinnedAccountIndex=1, gen=5
alt on-disk generation increased
proxy->>affinity: clearAll()
affinity-->>proxy: cleared
end
proxy->>proxy: chooseAccount(pinnedIndex=1)
proxy->>app: route to pinned account
app-->>user: response
estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes notes, risks, and missing tests
Suggested labels
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
✨ Simplify code
Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
The Codex desktop app chains chat turns via `previous_response_id`, which the runtime rotation proxy uses as a stable session key. Combined with default-on session affinity (20-minute TTL), this glues every turn of a chat to whichever account first responded. Manual `switch` and `unpin` had no way to break that lock — the CLI runs in a separate process from the long-running app router. This change adds an explicit cross-process invalidation path: * Storage gains an `affinityGeneration` counter (validated and dropped on out-of-range values, like `pinnedAccountIndex`). * `persistAndSyncSelectedAccount` accepts `bumpAffinityGeneration` and increments the counter before the disk write. * `switch`, `unpin`, and `best` all bump the counter. * `SessionAffinityStore` exposes `clearAll()` to drop every entry while preserving its TTL/maxEntries config. * `runtime-rotation-proxy` reads `affinityGeneration` alongside the pin via the shared mtime-cached helper. On `handleRequest`, before `chooseAccount`, the proxy compares the disk generation to its in-memory generation; if newer, it calls `clearAll()` so the same request benefits from the invalidation. The proxy never bumps `affinityGeneration` itself, so the proxy's own debounced disk writes do not trigger invalidation. Only the CLI does. Adds 19 behavioral tests in test/issue-474-affinity-invalidation.test.ts covering: counter increment from undefined and from a starting value, storage normalization of invalid values, clearAll semantics, mtime-cached disk read, and the integration-shape flow that invalidates a remembered session on bumped generation. Updates the existing switch test to assert the new flag. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
|
Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits. |
There was a problem hiding this comment.
Actionable comments posted: 12
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@lib/codex-manager.ts`:
- Around line 3268-3271: The current bumpAffinityGeneration code increments
storage.affinityGeneration on a mutable snapshot then calls
saveAccountsWithRetry, which allows concurrent switch/best/unpin to race and
drop a generation increment; instead move the pin/unpin + generation mutation
into the storage transactional/merge path so the increment is applied atomically
by the storage layer (modify the code paths that call bumpAffinityGeneration and
the storage.save/merge logic used by saveAccountsWithRetry/saveAccounts to
perform an atomic read-modify-write of affinityGeneration), add a Vitest race
regression that simulates concurrent switch/best/unpin calls to verify no lost
increments, and ensure the new queue/storage write logic properly
retries/backoffs on EBUSY and 429 errors per the lib/** concurrency guidelines.
- Around line 3505-3510: The unpin command currently calls
deps.saveAccounts(storage) directly inside runUnpinCommand; replace that call so
persistence goes through the existing saveAccountsWithRetry wrapper (the same
helper used by persistAndSyncSelectedAccount) to handle EBUSY/EPERM retries and
exponential backoff. Update runUnpinCommand (in the unpin command module) to
call saveAccountsWithRetry(storage, deps.saveAccounts) or equivalent helper, and
remove the raw saveAccounts invocation. Also extend the existing pin/unpin test
to add a case where saveAccounts throws a transient error (EBUSY/EPERM) on the
first attempts and succeeds later to verify retries are attempted (mock
saveAccounts to throw then succeed) and include a Windows file-lock style
failure scenario to ensure retry behavior is covered.
In `@lib/codex-manager/commands/status.ts`:
- Around line 189-197: The status printing currently assumes
storage.pinnedAccountIndex is valid; add a bounds check before printing so if
storage.pinnedAccountIndex is a number but <0 or >= storage.accounts.length you
log an explicit "invalid pin" or "pinned index out of range" warning instead of
"Pinned: account ..."; update the block that references pinnedAccountIndex and
runtimeCurrent (the same if (typeof pinnedAccountIndex === "number") branch) to
validate against storage.accounts.length and conditionally print either the
normal pinned message and runtime mismatch warning or an explicit invalid-pin
message, and add a regression in test/issue-474-pin-honored.test.ts that sets an
out-of-range pin and asserts the invalid-pin warning is emitted.
In `@lib/codex-manager/commands/unpin.ts`:
- Around line 33-34: The unpin command currently calls
deps.saveAccounts(storage) directly after bumping storage.affinityGeneration;
replace that direct call with the saveAccountsWithRetry wrapper from
lib/codex-manager/forecast-report-shared.ts (which retries on EBUSY/EPERM) so
the code matches other call sites like rotation.ts; specifically, in unpin.ts
update the save call to use saveAccountsWithRetry(deps, storage) (or the exact
exported helper name) and add a unit test that simulates transient EBUSY/EPERM
failures from deps.saveAccounts to assert the retry behavior and successful
final save.
- Around line 31-34: Add a regression test that concurrently runs
runUnpinCommand and runSwitchCommand to document that mutations (delete
pinnedAccountIndex + increment affinityGeneration) are applied in-memory and
persisted safely because deps.saveAccounts is serialized by withStorageLock;
implement the test (issue-474-affinity-invalidation.test.ts) to start both
commands in parallel (Promise.all), then read back the persisted accounts and
assert pinnedAccountIndex is removed and affinityGeneration reflects both
increments (e.g., initial +2), thereby proving the saved operations were queued
and not clobbered.
In `@lib/runtime-rotation-proxy.ts`:
- Around line 280-299: The cache keyed only by mtimeMs in STORAGE_META_CACHE and
used by readStorageMetaFromDisk is unsafe; change it to be keyed by storage path
(map from path -> StorageMetaSnapshot) or remove the cache entirely for this
tiny read, and use a stronger freshness signal (e.g., include file size and
inode/stat.ino or use the file contents hash) before returning cached
pinnedAccountIndex/affinityGeneration; update references to StorageMetaSnapshot
and getStoragePath accordingly. Also add a vitest that simulates two writes with
identical stat.mtimeMs (mock statSync to return same mtimeMs but different
contents) and assert readStorageMetaFromDisk returns the newest
pinnedAccountIndex/affinityGeneration, and ensure the test covers
Windows/coarse-timestamp behavior.
- Around line 287-324: The current read of the rotation storage (the try/catch
that uses statSync/readFileSync and updates STORAGE_META_CACHE.snapshot)
swallows all FS/JSON errors and returns the safe default ({ pinnedAccountIndex:
null, affinityGeneration: 0}) immediately; change it to detect transient FS
errors (EBUSY, EPERM, EACCES, partial-read/JSON parse) and retry the atomic read
sequence (statSync -> readFileSync -> JSON.parse) a few times with short
backoff, rechecking mtimeMs on each attempt, and only fall back to the default
after retries fail; preserve the existing validation of pinnedAccountIndex and
affinityGeneration and update STORAGE_META_CACHE.snapshot only on a successful
consistent read; add a vitest that simulates a locked/half-written storage file
to assert retries occur and that a transient failure does not immediately clear
pin/generation, and ensure any added logging around failures does not leak
tokens or emails and is referenced in the new test.
In `@test/issue-474-affinity-invalidation.test.ts`:
- Around line 1-82: Add a regression test that simulates concurrent CLI
processes bumping affinity by creating a temp storage with affinityGeneration:
10 (use makeTmpStoragePath and writeStorageFile with createStorage + overrides),
then concurrently invoke the three operations that increment affinity:
SessionAffinityStore.switch (or the CLI-equivalent switch invocation), the
"best" path that calls maybeInvalidateAffinityFromDisk/readStorageMetaFromDisk,
and runUnpinCommand (use runUnpinCommand with a UnpinCommandDeps stub), run them
in parallel (Promise.all) and after all complete read storage meta
(readStorageMetaFromDisk) and assert affinityGeneration === 13; use bumpMtime
between steps if needed to force disk change and resetPinCacheForTesting in
beforeEach/afterEach to avoid cache clobbering. Ensure the test file imports and
uses SessionAffinityStore, maybeInvalidateAffinityFromDisk, runUnpinCommand,
readStorageMetaFromDisk, makeTmpStoragePath, writeStorageFile, createStorage,
bumpMtime and verifies final state matches the expected combined increments.
- Around line 1-82: Add a new vitest case that simulates Windows EBUSY during
the affinity save when running runUnpinCommand: create a temp storage file with
an initial affinityGeneration, spy on the node:fs writeFileSync used by the save
flow (via vi.spyOn(fs, "writeFileSync")) to throw an Error object with code
"EBUSY" on the first 1-2 calls and then call through to the original
implementation, call runUnpinCommand with the test UnpinCommandDeps and the temp
path, and finally assert the on-disk JSON shows the affinityGeneration was
incremented and that writeFileSync was retried (use the spy callCount and final
file contents). Ensure you reset the spy and use resetPinCacheForTesting() in
before/after hooks like the other tests.
In `@test/issue-474-pin-honored.test.ts`:
- Around line 1-88: The test suite lacks a regression test for the concurrent
unpin+switch race; add a new test that creates storage with pinnedAccountIndex:
0 and affinityGeneration: 5 (use createStorage to set pinnedAccountIndex and
manually set affinityGeneration in the JSON written by writeStorageFile at a
path from makeTmpStoragePath), then spawn runUnpinCommand and
runSwitchCommand(..., "2") concurrently (Promise.all) against the same storage
file, use bumpMtime to simulate filesystem mtime changes if needed, and finally
read the file via readPinnedAccountIndexFromDisk or parse the file to assert
affinityGeneration === 7 and that the final pinnedAccountIndex matches the
winner (switch to index 2); reference runUnpinCommand, runSwitchCommand,
readPinnedAccountIndexFromDisk, affinityGeneration, pinnedAccountIndex,
makeTmpStoragePath, writeStorageFile, and bumpMtime when locating where to add
the test.
- Around line 1-88: Add a new vitest that simulates a transient Windows
file-lock error by mocking the saveAccounts dependency to throw an EBUSY (or
EPERM) error on the first call and succeed on the second, then run the command
(use runUnpinCommand or runSwitchCommand) and assert it exits 0 and the
pinnedAccountIndex is cleared; specifically, in the new test use
makeTmpStoragePath/writeStorageFile to create storage, stub the saveAccounts
method on the command deps (passed into runUnpinCommand or runSwitchCommand)
with vi.fn() to throw an Error-like object with code "EBUSY" (or "EPERM") once
and return normally thereafter, invoke the command, and read the storage file to
assert pinnedAccountIndex no longer exists. Ensure the test resets pin cache
(resetPinCacheForTesting) and cleans up tmp dirs like the other tests.
- Around line 489-497: The test "re-reads on mtime change (mtime cache
invalidation)" is flaky on Windows due to filesystem timestamp granularity;
update the test in issue-474-pin-honored.test.ts (the block using
makeTmpStoragePath, writeStorageFile, createStorage, bumpMtime,
readPinnedAccountIndexFromDisk) to handle Windows: either skip the test on
Windows with test.skip and a comment explaining granularity, or on win32 call
bumpMtime with a larger increment (e.g., +3s) or implement a small retry loop
that calls bumpMtime/readPinnedAccountIndexFromDisk until the index changes or a
short timeout elapses; ensure the chosen approach is documented in a comment and
uses process.platform === 'win32' to detect Windows.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 6d56e5b8-8c1f-41a8-a10f-49f58f897699
📒 Files selected for processing (17)
docs/reference/commands.mdlib/accounts.tslib/codex-manager.tslib/codex-manager/commands/best.tslib/codex-manager/commands/status.tslib/codex-manager/commands/switch.tslib/codex-manager/commands/unpin.tslib/codex-manager/help.tslib/routing-mutex.tslib/runtime-rotation-proxy.tslib/schemas.tslib/session-affinity.tslib/storage.tslib/storage/migrations.tstest/codex-manager-switch-command.test.tstest/issue-474-affinity-invalidation.test.tstest/issue-474-pin-honored.test.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Greptile Review
🧰 Additional context used
📓 Path-based instructions (3)
lib/**
⚙️ CodeRabbit configuration file
focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails.
Files:
lib/codex-manager/commands/status.tslib/codex-manager/help.tslib/codex-manager/commands/switch.tslib/codex-manager/commands/unpin.tslib/session-affinity.tslib/routing-mutex.tslib/codex-manager/commands/best.tslib/schemas.tslib/codex-manager.tslib/runtime-rotation-proxy.tslib/accounts.tslib/storage.tslib/storage/migrations.ts
test/**
⚙️ CodeRabbit configuration file
tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions.
Files:
test/codex-manager-switch-command.test.tstest/issue-474-affinity-invalidation.test.tstest/issue-474-pin-honored.test.ts
docs/**
⚙️ CodeRabbit configuration file
keep README, SECURITY, and docs consistent with actual CLI flags and workflows. whenever behavior changes, require updated upgrade notes and mention new npm scripts.
Files:
docs/reference/commands.md
🧠 Learnings (1)
📓 Common learnings
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-09T19:46:35.731Z
Learning: Use `codex-multi-auth` as the primary account-manager entrypoint for bare subcommands such as `status`, `login`, and `rotation status`
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-09T19:46:35.731Z
Learning: Support compatibility forms for codex-multi-auth commands: `codex-multi-auth auth ...`, `codex-multi-auth-codex auth ...`, `codex auth ...` (when wrapper is installed), `codex multi auth ...`, `codex multi-auth ...`, and `codex multiauth ...`
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-09T19:46:35.731Z
Learning: Use `codex-multi-auth login` as the primary interactive auth dashboard command (browser-first by default)
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-09T19:46:35.731Z
Learning: Support `codex-multi-auth login --device-auth` flag for OpenAI Codex device-code flow in remote/headless environments (mutually exclusive with `--manual` / `--no-browser`)
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-09T19:46:35.731Z
Learning: Support `codex-multi-auth login --manual` or `--no-browser` flag to skip browser launch and use manual callback flow (mutually exclusive with `--device-auth`)
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-09T19:46:35.731Z
Learning: When `CODEX_AUTH_NO_BROWSER=1` is set, suppress browser launch for automation/headless sessions; false-like values such as `0` and `false` do not disable browser launch
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-09T19:46:35.731Z
Learning: In non-TTY/manual shells, accept the full redirect URL on stdin for `codex-multi-auth login --manual`, for example: `echo "http://127.0.0.1:1455/auth/callback?code=..." | codex-multi-auth login --manual`
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-09T19:46:35.731Z
Learning: In non-TTY or host-managed sessions (CODEX_TUI=1, CODEX_DESKTOP=1, TERM_PROGRAM=codex, ELECTRON_RUN_AS_NODE=1), auth flows must degrade to deterministic text behavior
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-09T19:46:35.731Z
Learning: Make `codex-multi-auth login` in non-TTY fallback default to add-account mode, skip the extra 'add another account' prompt, and auto-pick the default workspace selection when follow-up choice is needed
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-09T19:46:35.731Z
Learning: Store local account policy metadata using hashed policy keys from account identity; do not store raw account IDs or raw emails in the policy file
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-09T19:46:35.731Z
Learning: Normalize account `tag` values to lowercase filesystem-safe labels in `codex-multi-auth account tag` command
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-09T19:46:35.731Z
Learning: Accept `weight` values from `0` to `10` (default `1`) in `codex-multi-auth account weight` command with values outside range rejected or clamped per implementation
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-09T19:46:35.731Z
Learning: When implementing `codex-multi-auth switch`, `codex-multi-auth unpin`, and `codex-multi-auth best`, bump an `affinityGeneration` counter in storage that the runtime rotation proxy observes to drop session-affinity store entries when the generation increases
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-09T19:46:35.731Z
Learning: Do not store raw account emails, raw sensitive account IDs, prompts, or tokens in local usage ledger rows; only store local-only metadata
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-09T19:46:35.731Z
Learning: Support `--since` flag in `codex-multi-auth usage` to filter rows by Unix milliseconds, ISO date, or relative duration such as `24h`, `7d`, or `2w`
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-09T19:46:35.731Z
Learning: Support `--by` flag in `codex-multi-auth usage` to group summary output by `model`, `account`, `project`, `outcome`, or `day` (default: `model`)
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-09T19:46:35.731Z
Learning: Support `codex-multi-auth usage rotate` to move the current ledger to a timestamped archive with optional `--if-larger-than-bytes` threshold
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-09T19:46:35.731Z
Learning: The local bridge exposes only `/health`, `/v1/models`, and `/v1/responses` endpoints on loopback and requires bearer token authentication by default
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-09T19:46:35.731Z
Learning: Plain local bridge tokens must be printed only on `create` and `rotate` operations; the token store persists SHA-256 hashes plus prefixes and labels
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-09T19:46:35.731Z
Learning: Generated bridge integration snippets must use `CODEX_MULTI_AUTH_LOCAL_KEY` as the bearer token variable; Python snippets must use `client.responses.create`
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-09T19:46:35.731Z
Learning: When rotation proxy cannot select an account because all accounts are unavailable, return `codex_runtime_rotation_pool_exhausted` error with a retry hint pointing to `codex-multi-auth rotation status`
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-09T19:46:35.731Z
Learning: When runtime rotation is enabled and a Codex desktop app is detected during package install/update, auto-bind the app by default; respect `CODEX_MULTI_AUTH_APP_BIND_INSTALL=0` to skip and `CODEX_MULTI_AUTH_APP_BIND_INSTALL=1` to force
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-09T19:46:35.731Z
Learning: When app launcher routing is installed on Windows, retarget existing user-level `Codex` shortcuts and taskbar pins to the wrapper while backing up their original target for restore
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-09T19:46:35.731Z
Learning: When app launcher routing is installed on macOS, create or remove a user-level `Codex Multi Auth.app` wrapper since Dock entries cannot safely launch a shell command directly
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-09T19:46:35.731Z
Learning: Run `codex-multi-auth verify --paths` to test the storage-path resolution chain and sandbox self-test that verifies `resolvePath` accepts paths inside home and temp directories but rejects outside-sandbox escape candidates
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-09T19:46:35.731Z
Learning: When implementing `codex-multi-auth verify --paths`, construct the escape candidate outside the home, temp, and project roots to stay robust when invoked from pathological working directories; skip the probe with `ok: true` if no guaranteed-outside candidate can be constructed
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-09T19:46:35.731Z
Learning: Model speed/reasoning controls remain Codex-owned; for wrapper-launched CLI sessions set `model_reasoning_effort` in `~/.codex/config.toml` or pass `-c model_reasoning_effort=<level>`
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-09T19:46:35.731Z
Learning: Support `codex-multi-auth --version` and `codex-multi-auth -v` to report the installed manager package version; `codex --version` reports the official `openai/codex` CLI version when the official CLI owns the `codex` name
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-09T19:46:35.731Z
Learning: Document related resources including ../features.md, public-api.md, error-contracts.md, settings.md, and ../troubleshooting.md in the command reference
🔇 Additional comments (19)
lib/codex-manager/help.ts (1)
13-14: looks good.the usage text in
lib/codex-manager/help.ts:13-14now matches the pin/unpin runtime routing behavior and is consistent with the issue-474 flow.lib/routing-mutex.ts (1)
49-49: lgtm.
lib/routing-mutex.ts:49correctly extends selection reason typing with"manual"and keeps mutex contracts unchanged.test/codex-manager-switch-command.test.ts (1)
85-87: good regression update.
test/codex-manager-switch-command.test.ts:85-87andtest/codex-manager-switch-command.test.ts:93now assert the manual pin contract (switchReason: "manual",setPin,bumpAffinityGeneration) and updated operator message.Also applies to: 93-93
lib/session-affinity.ts (1)
206-215: looks correct.
lib/session-affinity.ts:206-215adds the needed store-wide invalidation primitive with safe no-op behavior on empty state.lib/schemas.ts (1)
120-120: schema changes are aligned.
lib/schemas.ts:120andlib/schemas.ts:187-188correctly encode the new manual-switch reason plus pinned/affinity storage fields.Also applies to: 187-188
docs/reference/commands.md (1)
37-50: nice documentation update.
docs/reference/commands.md:37-50clearly documents the pin/unpin flow and the affinity-generation invalidation behavior for runtime proxy routing.lib/codex-manager/commands/best.ts (2)
321-330: lgtm: pin-clearing logic is correct.capturing
priorPinbefore the persist call and determiningpinWasClearedby checking undefined is the right approach. the flagsclearPin: trueandbumpAffinityGeneration: truealign with the pr objectives for invalidating session affinity on manual changes.based on learnings: when implementing
codex-multi-auth switch,codex-multi-auth unpin, andcodex-multi-auth best, bump anaffinityGenerationcounter in storage that the runtime rotation proxy observes to drop session-affinity store entries when the generation increases.
351-351: tests correctly assert "manual pin cleared" log output in both scenarios.test/issue-474-pin-honored.test.ts:21-93 covers this properly. the first test "clears the pin via clearPin: true and reports it when a prior pin was set" (with pinnedAccountIndex: 0) asserts the string appears (line 188), and the second test "does not announce pin cleared when no prior pin existed" (no pinnedAccountIndex) asserts it doesn't appear (line 251). implementation at lib/codex-manager/commands/best.ts:331 correctly ties this to
priorPin !== undefined, and the log message at line 351 only appends the string whenpinWasClearedis true.lib/storage.ts (2)
1251-1267: lgtm: pinnedAccountIndex validation is thorough.truncating to integer with
Math.trunc, checking bounds againstdeduplicatedAccounts.length, and logging warnings when dropping invalid values is the right defensive approach. the validation prevents out-of-range pins from persisting.
1269-1284: lgtm: affinityGeneration validation handles edge cases.rejecting negative values, non-integers, and non-finite numbers with warnings prevents corrupted generation counters from breaking affinity invalidation logic in
lib/runtime-rotation-proxy.ts.lib/codex-manager/commands/switch.ts (2)
68-70: lgtm: manual pin flags are correct.passing
switchReason: "manual",setPin: true, andbumpAffinityGeneration: truetopersistAndSyncSelectedAccountmatches the pr's pin-and-invalidate contract. this ensures the runtime proxy reads the pin from disk and clears session affinity on the next request.based on learnings: when implementing
codex-multi-auth switch,codex-multi-auth unpin, andcodex-multi-auth best, bump anaffinityGenerationcounter in storage that the runtime rotation proxy observes to drop session-affinity store entries when the generation increases.
80-80: tests already assert "(pinned for runtime routing)" log message.confirmed:
test/issue-474-pin-honored.test.ts:114usesexpect.stringContaining("(pinned for runtime routing)")andtest/codex-manager-switch-command.test.ts:93has the exact assertion with the full message. regression test coverage is in place.lib/accounts.ts (1)
253-259: lgtm: "manual" switch reason is consistently added.extending
lastSwitchReasonto include"manual"acrossManagedAccount,markSwitched, andmarkSwitchedLockedis correct. the reason is persisted toaccount.lastSwitchReasonfor audit/diagnostic purposes.Also applies to: 871-871, 913-913
lib/codex-manager/commands/unpin.ts (1)
26-29: lgtm: idempotent when no pin exists.early return when
storage.pinnedAccountIndex === undefinedprevents unnecessary saves and logs. the log message "no pin to clear." matches expected user-facing output.lib/storage/migrations.ts (1)
79-94: lgtm: documentation clarifies runtime proxy contract.the comments on
pinnedAccountIndexandaffinityGenerationclearly describe the runtime proxy's routing behavior and cross-process invalidation contract. the migration function correctly omits these fields when migrating from v1, leaving them undefined.test/issue-474-pin-honored.test.ts (1)
92-116: lgtm: switch command test verifies pin flags.the test asserts
persistAndSyncSelectedAccountis called withswitchReason: "manual",setPin: true, and that the log includes"(pinned for runtime routing)". this covers the core behavior introduced inlib/codex-manager/commands/switch.ts:68-80.test/issue-474-affinity-invalidation.test.ts (3)
85-124: lgtm: affinityGeneration normalization tests are comprehensive.the tests cover valid integers, undefined (logically zero), negative values, NaN, and non-integer floats. this matches the validation logic in
lib/storage.ts:1269-1284.
127-162: lgtm: SessionAffinityStore.clearAll() tests are complete.the tests verify clearAll removes all entries, is a no-op on empty stores, and preserves the configured ttl. this covers the behavior introduced for session affinity invalidation.
232-267: lgtm: unpin increments affinityGeneration correctly.the tests verify
runUnpinCommandincrementsaffinityGenerationfromundefined → 1and5 → 6, and callssaveAccounts(storage). this matches the implementation inlib/codex-manager/commands/unpin.ts:33-34.
) Two confidence-raising additions on top of the existing #474 fix: * Replace the mtime-only cache key in readPinnedAccountIndexFromDisk with a content-hash cache. We always stat-and-read the storage file (cheap; a few KB of JSON), but JSON.parse is skipped when the SHA-1 of the bytes matches the cached digest. This eliminates the Windows sub-millisecond mtime granularity concern: two writes within the same mtime tick will still invalidate via different bytes. * Add test/issue-474-pin-end-to-end.test.ts that spins up the actual startRuntimeRotationProxy HTTP server with a mocked fetch upstream and exercises the full seam: real http.request POST to the proxy, pin write to the storage file mid-flight, second http.request that must land on the pinned account, then a third request after pinning to a disabled account that must respond HTTP 503 with code codex_pinned_account_unavailable. This proves end-to-end that: - the proxy honors a pin set by another process without a restart, - session affinity is invalidated mid-conversation by a CLI-bumped affinityGeneration, - hard-fail-on-pinned-unavailable is wired all the way through the HTTP response, not just chooseAccount. Existing pin and affinity tests updated where they asserted mtime-equality semantics that no longer apply (the cache is now keyed by content hash). Total: 3955 tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…mps (#474) Address PR #475 review feedback: - unpin.ts now wraps saveAccounts via saveAccountsWithRetry so a transient EBUSY/EPERM (common on Windows when the proxy briefly holds the file) no longer silently loses the user's clear-pin intent. - runtime-rotation-proxy STORAGE_META_CACHE is now keyed by absolute storage path (Map) so concurrent vitest workers and multiple proxy instances pointing at different files cannot corrupt each other. - readStorageMetaFromDisk now treats EBUSY/EPERM/EACCES/EAGAIN and JSON parse errors (likely partial reads mid atomic-rename) as transient: it retries up to 3 times with tiny backoff, then falls back to the last cached snapshot for that path. Returning defaults on a transient error would have falsely reported "no pin, no affinity bump" and let the proxy use the wrong account. - persistAndSyncSelectedAccount and runUnpinCommand now re-read the on-disk affinityGeneration just before saving and apply Math.max(inMemory, disk) + 1 so concurrent CLI processes cannot lose increments via a load+mutate lost update. Helper readAffinityGenerationFromDisk lives in storage.ts. - status.ts bounds-checks pinnedAccountIndex against storage.accounts.length and surfaces an actionable "run codex-multi-auth unpin" hint when the pin is out of range.
…umps (#474) Add issue-474-pin-safety.test.ts covering the PR #475 review fixes: - unpin retries on transient EBUSY via saveAccountsWithRetry - STORAGE_META_CACHE per-path isolation (no cross-contamination across concurrent vitest workers / multiple proxies) - readStorageMetaFromDisk preserves the last cached snapshot on a partial-write parse error mid atomic-rename, and falls back to defaults only when no cache exists - status reports "invalid account index … run codex-multi-auth unpin" for out-of-range pins (positive and negative) - unpin uses Math.max(inMemory, disk) + 1 when the on-disk generation is ahead of the loaded snapshot, and gracefully falls back to inMemory + 1 when no getStoragePath dep is supplied - Two concurrent unpin processes against the same storage file converge to >= initialGen + 2 (no lost increments)
|
@coderabbitai review All prior review findings (CodeRabbit + Greptile) are addressed across the latest 2 commits. Mapping below for context.
Gates: typecheck ✅, lint ✅ (0 warnings), |
|
✅ Actions performedFull review triggered. |
There was a problem hiding this comment.
Actionable comments posted: 9
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
test/codex-manager-switch-command.test.ts (1)
70-95: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick winlgtm on the assertions, but consider one edge case.
test/codex-manager-switch-command.test.ts:81-94correctly asserts the newsetPin: true+bumpAffinityGeneration: true+switchReason: "manual"contract. fake tokens are used (no real secrets), so no test/** rule violations.minor gap: there's no case asserting
switch <currentlyActive>still passessetPin: trueandbumpAffinityGeneration: true. that is the exact path a frustrated user takes to re-assert intent when the desktop app feels stuck, and it is the most common manual-recovery flow. cheap regression to add sincecreateDeps()is already factored out.proposed extra case
+ it("still pins and bumps generation when re-selecting the active account", async () => { + const deps = createDeps(); + + const result = await runSwitchCommand(["1"], deps); + + expect(result).toBe(0); + expect(deps.persistAndSyncSelectedAccount).toHaveBeenCalledWith( + expect.objectContaining({ + targetIndex: 0, + switchReason: "manual", + setPin: true, + bumpAffinityGeneration: true, + }), + ); + });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/codex-manager-switch-command.test.ts` around lines 70 - 95, Add a new test case that covers switching to the currently active account to ensure the same pin/bump contract is preserved: call runSwitchCommand(["<currentIndex>"], using the same createDeps() pattern) and assert that deps.persistAndSyncSelectedAccount was called with setPin: true and bumpAffinityGeneration: true (and switchReason: "manual"), and assert expected logInfo/logWarn behavior; locate this next to the existing "persists and reports the selected account" test and reuse createDeps, runSwitchCommand, and persistAndSyncSelectedAccount to mirror the manual-recovery flow.docs/reference/commands.md (1)
390-398:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winupgrade notes section incomplete for pin/unpin/affinity behavior.
docs/reference/commands.md:390-398 covers prior auth-flow work but omits user-observable runtime behavior changes from
#474: (a)switchnow pins to runtime routing, (b)unpinclears manual pins, (c)bestclears pins, (d) all three bumpaffinityGenerationto invalidate sticky session affinity cross-process. these commands are documented in the table at lines 37-50 and the affinity blockquote, but per coding guidelines, upgrade notes should explicitly surface user-facing behavior changes for desktop-app users.also:
codex_pinned_account_unavailableerror from lib/runtime-rotation-proxy.ts (when pinned account is unhealthy) isn't in docs/reference/error-contracts.md and should be documented there.lastly, the "no new npm scripts or storage migration steps" note on line 398 is accurate but readers would benefit from "older clients safely ignore the new optional
pinnedAccountIndexandaffinityGenerationfields" since this is a forward-compat schema bump.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/reference/commands.md` around lines 390 - 398, Update the Upgrade Notes and error docs to list the runtime-routing and affinity changes from PR `#474`: state that the `switch` command now pins runtime routing, `unpin` clears manual pins, and `best` clears pins, and that all three increment `affinityGeneration` to invalidate sticky-session affinity across processes; add a note that older clients can safely ignore the new optional `pinnedAccountIndex` and `affinityGeneration` fields for forward compatibility; add an entry for the `codex_pinned_account_unavailable` error (raised in lib/runtime-rotation-proxy.ts when a pinned account is unhealthy) to docs/reference/error-contracts.md so users see the runtime failure mode.
♻️ Duplicate comments (2)
test/issue-474-pin-honored.test.ts (2)
251-292: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick winunpin test does not exercise the EBUSY/EPERM retry path on
saveAccounts.
test/issue-474-pin-honored.test.ts:251-292only covers happy-path and idempotent unpin. the pr description explicitly notesunpinwas migrated tosaveAccountsWithRetryto handle transient windows file locks; that retry path has no regression test here. add a case wheredeps.saveAccountsthrows anErrorwithcode = "EBUSY"on first call and resolves on second, then assertrunUnpinCommandreturns0andpinnedAccountIndexis cleared.as per coding guidelines: "verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/issue-474-pin-honored.test.ts` around lines 251 - 292, Add a new test case that simulates the EBUSY retry path by mocking deps.saveAccounts to throw an Error with code = "EBUSY" on the first invocation and resolve on the second, then call runUnpinCommand(deps) and assert it returns 0, storage.pinnedAccountIndex is cleared (undefined), saveAccounts was invoked twice, and logInfo was called with a message containing "Cleared manual pin"; locate the test harness scaffolding (createStorage, UnpinCommandDeps, runUnpinCommand) in the existing file to reuse the same setup and assert the retry behavior for transient Windows file-lock errors.
84-292: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick winstill missing: concurrent
unpin+switchrace regression.past review flagged this and it has not landed in
test/issue-474-pin-honored.test.ts. you cover single-process behavior thoroughly, but the contractaffinityGenerationis supposed to defend (cross-process bumps without lost increments) is still untested here. spawnrunUnpinCommandandrunSwitchCommand(["3"], ...)against the same on-disk storage viaPromise.all, then read the file back and assertaffinityGenerationadvanced by exactly 2 with the last writer's pin state intact. otherwise the lost-increment fix referenced in the pr description has no behavioral coverage in this file.as per coding guidelines: "tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/issue-474-pin-honored.test.ts` around lines 84 - 292, Add a deterministic concurrency test that spawns runUnpinCommand and runSwitchCommand(["3"], ...) concurrently against the same on-disk storage and then validates affinityGeneration and final pin state; specifically, create a real storage file (using the same storage backend used by createStorage), start both commands with Promise.all, re-load the storage file, and assert storage.affinityGeneration increased by exactly 2 and that storage.pinnedAccountIndex matches the last writer (the switch to index 3) — use the existing helpers runUnpinCommand, runSwitchCommand, createStorage, and inspect affinityGeneration and pinnedAccountIndex to implement this regression test in vitest so it is deterministic.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/reference/commands.md`:
- Around line 41-51: Move the sticky-affinity blockquote out of the daily-use
table in docs/reference/commands.md by placing the blockquote after the table’s
closing row (so the `| codex-multi-auth account ...` row is not orphaned), then
update the Upgrade Notes section in the same file to document the user-facing
behavior changes: `switch` now pins, `unpin` clears the pin, and `best` clears
the pin (add concise upgrade guidance under the Upgrade Notes heading referenced
around docs/reference/commands.md:390+). Finally, add the new error code
`codex_pinned_account_unavailable` to the "Runtime Rotation Proxy Error
Contract" table in docs/reference/error-contracts.md with HTTP 503 and a short
meaning matching lib/runtime-rotation-proxy.ts and
test/issue-474-pin-end-to-end.test expectations so the docs reflect the
implemented error contract.
In `@lib/codex-manager.ts`:
- Around line 3179-3192: The parameter type for the switch reason is duplicated
as a literal union in the function signature (the parameter on
setPin/clearPin/bumpAffinityGeneration block) and in
lib/codex-manager/commands/switch.ts; instead export a concrete TypeScript type
from the canonical SwitchReasonSchema in lib/schemas.ts (e.g. export type
SwitchReason = z.infer<typeof SwitchReasonSchema>) and import and use that
exported SwitchReason type in both the codex-manager function signature and in
lib/codex-manager/commands/switch.ts, replacing the inline `"rotation" | "best"
| "restore" | "manual"` union so both files stay in sync.
In `@lib/codex-manager/commands/status.ts`:
- Around line 189-206: The current check uses typeof pinnedAccountIndex ===
"number" and adds +1 when reporting invalid indices which both misreports
negative values and allows NaN through; change the guard to use
Number.isInteger(storage.pinnedAccountIndex) (or Number.isFinite + integer test)
to reject NaN and non-integers, and when the stored index is out of bounds log
the raw stored value (storage.pinnedAccountIndex) rather than
storage.pinnedAccountIndex + 1 so negatives are shown as-is; keep the existing
success path that logs `Pinned: account ${pinnedAccountIndex + 1} (set by
switch)` and the runtimeCurrent/index warning unchanged but only run it after
the stricter integer + bounds check.
In `@lib/codex-manager/help.ts`:
- Line 15: Update the help text that contains the string " codex-multi-auth
best [--live] [--json] [--model <model>]" to explicitly state that the best
command clears any manual pin set by the switch command (e.g. append a short
note like "(clears any manual pin set by switch)") following the same
phrasing/style used on line 13; locate and modify the help export in help.ts
where that literal appears so users know best will override a pinned account set
via switch.
In `@lib/runtime-rotation-proxy.ts`:
- Around line 348-352: The parsing for pinnedAccountIndex should reject negative
or non-integer values: update the logic that sets
StorageMetaSnapshot.pinnedAccountIndex (currently using
parsed.pinnedAccountIndex and Math.trunc) to only accept values where
Number.isFinite(parsed.pinnedAccountIndex) &&
Number.isInteger(parsed.pinnedAccountIndex) && parsed.pinnedAccountIndex >= 0;
otherwise set it to null. Replace the current Math.trunc-based branch that
produces -1, -0, or truncated floats with this stricter check so callers like
chooseAccount and readPinnedAccountIndexFromDisk only see schema-permitted
values.
- Around line 370-378: The code currently does a synchronous busy-wait in
readStorageMetaFromDisk; change readStorageMetaFromDisk to be async (returning a
Promise) and replace the Date.now() spin loop with an awaited sleep (e.g. await
new Promise(r => setTimeout(r, 5 + attempt * 5))). Also make
maybeInvalidateAffinityFromDisk and readPinnedAccountIndexFromDisk async if they
call/readStorageMetaFromDisk, and update their callers (the startup init factory
and the per-request path that calls the per-request read) to await these calls;
ensure forwardStreamingResponse and any request handlers remain asynchronous and
do not perform synchronous waits. Update any tests to await the new async
helpers.
In `@lib/schemas.ts`:
- Around line 187-189: buildStorageSnapshot currently omits pinnedAccountIndex
and affinityGeneration causing disk writes (via saveToDisk, saveToDiskDebounced,
commitRefreshedAuth) to drop user pins; update buildStorageSnapshot in
AccountManager to preserve these values by either (A) reading the current
on-disk storage blob and merging pinnedAccountIndex and affinityGeneration into
the returned V3 snapshot before returning, or (B) add instance fields
pinnedAccountIndex and affinityGeneration to AccountManager (set when loading or
applying switch/unpin/best) and include those fields in buildStorageSnapshot so
subsequent saves keep them; refer to the buildStorageSnapshot function and
AccountManager methods saveToDisk/saveToDiskDebounced/commitRefreshedAuth to
ensure the chosen approach is used consistently.
In `@test/issue-474-affinity-invalidation.test.ts`:
- Around line 219-253: Add a new test that simulates concurrent execution of
three different commands that call persistAndSyncSelectedAccount (e.g., unpin,
switch, best) to ensure affinity bumps are not lost: create a shared storage
object with an initial affinityGeneration, stub loaders (loadAccounts) to return
that same storage instance for each command, and invoke the three command
runners (runUnpinCommand, runSwitchCommand, runBestCommand or equivalents)
concurrently via Promise.all; after completion assert storage.affinityGeneration
>= initial+3 and that saveAccounts was called as expected. Ensure the test
references persistAndSyncSelectedAccount behavior indirectly by using the
existing command runners and uses the same mocked deps pattern as the other
tests so it exercises the Math.max re-read safety across command boundaries.
In `@test/issue-474-pin-honored.test.ts`:
- Around line 465-509: Add a test that exercises the proxy's transient-fs retry
by stubbing fs.readFileSync (use vi.spyOn on a fresh import of fs or inject a
seam) to throw an EBUSY error on the first call and then return valid JSON bytes
on the next call, and assert readPinnedAccountIndexFromDisk(...) still returns
the correct pinned index; also add a second test where readFileSync always
throws EBUSY and assert the function falls back to the previous cached snapshot
(returns the last known index) instead of null. Locate the code under test via
the readPinnedAccountIndexFromDisk helper used in the existing suite and the
retry logic in the runtime-rotation-proxy read/retry path, ensure you restore
the spy after each test, and use the same makeTmpStoragePath/writeStorageFile
helpers so the cache behavior mirrors current tests.
---
Outside diff comments:
In `@docs/reference/commands.md`:
- Around line 390-398: Update the Upgrade Notes and error docs to list the
runtime-routing and affinity changes from PR `#474`: state that the `switch`
command now pins runtime routing, `unpin` clears manual pins, and `best` clears
pins, and that all three increment `affinityGeneration` to invalidate
sticky-session affinity across processes; add a note that older clients can
safely ignore the new optional `pinnedAccountIndex` and `affinityGeneration`
fields for forward compatibility; add an entry for the
`codex_pinned_account_unavailable` error (raised in
lib/runtime-rotation-proxy.ts when a pinned account is unhealthy) to
docs/reference/error-contracts.md so users see the runtime failure mode.
In `@test/codex-manager-switch-command.test.ts`:
- Around line 70-95: Add a new test case that covers switching to the currently
active account to ensure the same pin/bump contract is preserved: call
runSwitchCommand(["<currentIndex>"], using the same createDeps() pattern) and
assert that deps.persistAndSyncSelectedAccount was called with setPin: true and
bumpAffinityGeneration: true (and switchReason: "manual"), and assert expected
logInfo/logWarn behavior; locate this next to the existing "persists and reports
the selected account" test and reuse createDeps, runSwitchCommand, and
persistAndSyncSelectedAccount to mirror the manual-recovery flow.
---
Duplicate comments:
In `@test/issue-474-pin-honored.test.ts`:
- Around line 251-292: Add a new test case that simulates the EBUSY retry path
by mocking deps.saveAccounts to throw an Error with code = "EBUSY" on the first
invocation and resolve on the second, then call runUnpinCommand(deps) and assert
it returns 0, storage.pinnedAccountIndex is cleared (undefined), saveAccounts
was invoked twice, and logInfo was called with a message containing "Cleared
manual pin"; locate the test harness scaffolding (createStorage,
UnpinCommandDeps, runUnpinCommand) in the existing file to reuse the same setup
and assert the retry behavior for transient Windows file-lock errors.
- Around line 84-292: Add a deterministic concurrency test that spawns
runUnpinCommand and runSwitchCommand(["3"], ...) concurrently against the same
on-disk storage and then validates affinityGeneration and final pin state;
specifically, create a real storage file (using the same storage backend used by
createStorage), start both commands with Promise.all, re-load the storage file,
and assert storage.affinityGeneration increased by exactly 2 and that
storage.pinnedAccountIndex matches the last writer (the switch to index 3) — use
the existing helpers runUnpinCommand, runSwitchCommand, createStorage, and
inspect affinityGeneration and pinnedAccountIndex to implement this regression
test in vitest so it is deterministic.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 66653b65-4555-47a6-a2b5-31128daa4b7b
📒 Files selected for processing (19)
docs/reference/commands.mdlib/accounts.tslib/codex-manager.tslib/codex-manager/commands/best.tslib/codex-manager/commands/status.tslib/codex-manager/commands/switch.tslib/codex-manager/commands/unpin.tslib/codex-manager/help.tslib/routing-mutex.tslib/runtime-rotation-proxy.tslib/schemas.tslib/session-affinity.tslib/storage.tslib/storage/migrations.tstest/codex-manager-switch-command.test.tstest/issue-474-affinity-invalidation.test.tstest/issue-474-pin-end-to-end.test.tstest/issue-474-pin-honored.test.tstest/issue-474-pin-safety.test.ts
📜 Review details
🧰 Additional context used
📓 Path-based instructions (3)
lib/**
⚙️ CodeRabbit configuration file
focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails.
Files:
lib/schemas.tslib/codex-manager/commands/status.tslib/codex-manager/help.tslib/storage/migrations.tslib/codex-manager/commands/switch.tslib/routing-mutex.tslib/session-affinity.tslib/codex-manager/commands/best.tslib/storage.tslib/codex-manager/commands/unpin.tslib/codex-manager.tslib/accounts.tslib/runtime-rotation-proxy.ts
test/**
⚙️ CodeRabbit configuration file
tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions.
Files:
test/codex-manager-switch-command.test.tstest/issue-474-pin-end-to-end.test.tstest/issue-474-pin-safety.test.tstest/issue-474-pin-honored.test.tstest/issue-474-affinity-invalidation.test.ts
docs/**
⚙️ CodeRabbit configuration file
keep README, SECURITY, and docs consistent with actual CLI flags and workflows. whenever behavior changes, require updated upgrade notes and mention new npm scripts.
Files:
docs/reference/commands.md
🧠 Learnings (1)
📓 Common learnings
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-10T04:45:43.613Z
Learning: Use `codex-multi-auth` as the primary account-manager entrypoint for bare subcommands such as `status`, `login`, and `rotation status`
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-10T04:45:43.613Z
Learning: Use `codex-multi-auth login --device-auth` for remote/headless login instead of browser-first flows when an OpenAI Codex device-code flow is preferred
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-10T04:45:43.613Z
Learning: Suppress browser launch for automation/headless sessions using `CODEX_AUTH_NO_BROWSER=1` environment variable
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-10T04:45:43.613Z
Learning: In non-TTY/manual shells, pass the full redirect URL on stdin when using `codex-multi-auth login --manual` (e.g., `echo 'http://127.0.0.1:1455/auth/callback?code=...' | codex-multi-auth login --manual`)
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-10T04:45:43.613Z
Learning: Use `codex-multi-auth verify --all` to run both storage-path resolution chain and flagged-account verification together in installation self-tests
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-10T04:45:43.613Z
Learning: Use workflow packs for health planning: `codex-multi-auth check`, `codex-multi-auth forecast --live --explain --model <model>`, and `codex-multi-auth report --live --json`
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-10T04:45:43.613Z
Learning: Use workflow packs for repair and recovery: `codex-multi-auth fix --dry-run`, `codex-multi-auth fix --live --model <model>`, and `codex-multi-auth doctor --fix`
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-10T04:45:43.613Z
Learning: When runtime rotation is enabled, packaged app install/update runs bind by default; set `CODEX_MULTI_AUTH_APP_BIND_INSTALL=0` to skip self-heal or `CODEX_MULTI_AUTH_APP_BIND_INSTALL=1` to force it
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-10T04:45:43.613Z
Learning: Set `CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY=0` to disable the rotation proxy for the current process without changing persisted settings
Learnt from: CR
Repo: ndycode/codex-multi-auth
Timestamp: 2026-05-10T04:45:43.613Z
Learning: Use `codex-multi-auth-app-launcher --remove` on Windows to restore backed-up shortcuts or on macOS to remove the managed wrapper
🔇 Additional comments (23)
lib/codex-manager/help.ts (1)
13-14: lgtm - pin documentation is clear.the help text accurately documents the new pin behavior for
switchand the newunpincommand. placement in "daily use" makes sense.lib/codex-manager.ts (3)
3262-3282: 🏗️ Heavy liftduplicate of an earlier concurrency concern — the read-then-save still has a lost-update window.
lib/codex-manager.ts:3277-3280: this is the same concern from the prior review pass (lines 3268-3282, marked addressed). the read-from-disk + max + write narrows the window but does not close it: process A reads disk=N, in-memory=N, computes N+1; process B reads disk=N, in-memory=N, computes N+1; both queue behindwithStorageLockand write N+1 — one bump is structurally lost. acknowledging this as duplicate since the author explicitly documents the trade-off in the comment ("this only narrows the lost-update window").worth pointing out one more time though: the truly atomic fix is to re-read inside the lock during the save path, i.e.
saveAccountsWithRetry(orwithAccountStorageTransaction) accepts a mutator callback that is handed the freshly-loaded on-disk storage and returns the next state. that's the same shapewithAccountStorageTransactionalready uses elsewhere in this file (e.g.lib/codex-manager.ts:2671,:2705,:3389) — so it's not a new pattern, just adoption.leaving as duplicate; not blocking if the concurrency tests in
test/issue-474-pin-safety.test.tsgive you confidence in the narrowed window for now.
3266-3268: 🏗️ Heavy lift
delete storage.pinnedAccountIndex— fine, but be aware of thebuildStorageSnapshotinteraction.
lib/codex-manager.ts:3267: removing the property is correct. but the pr description flags an outstanding greptile finding thatbuildStorageSnapshotomits bothpinnedAccountIndexandaffinityGeneration. if the debounced error-path save callsbuildStorageSnapshotand round-trips a snapshot back to disk, the snapshot never carries the pin in the first place — anddeletehere vs= undefineddoesn't matter because the field is already gone. the real bug is upstream. raised separately onlib/schemas.ts; cross-linking here so it's clear this is the same root cause, not a separate issue.
3264-3281: ⚡ Quick winerror handling for
readAffinityGenerationFromDiskis already in place — no fix needed.lib/storage.ts:1312 defines
readAffinityGenerationFromDiskwith a try/catch that blanket-catches all exceptions (EBUSY, EPERM, ENOENT, SyntaxError) and returns 0. the unwrapped call at lib/codex-manager.ts:3277 is therefore safe: any fs error results in diskGeneration=0, which feeds the Math.max(inMemory, 0)+1 logic, achieving exactly the fallback behavior the review proposed. no regression test needed since the handler already exists.the secondary point about sync io on multi-MB files stalling the event loop is valid (affects proxy hot path if code moves there), but is a general performance note, not a Windows edge case — the existing comment acknowledging "this only narrows the window" already flags the intent. consider adding a perf comment if you reuse this in the proxy, but not required for this change.
> Likely an incorrect or invalid review comment.lib/session-affinity.ts (1)
206-215: lgtm —clearAllis a clean, deterministic invalidation hook.
lib/session-affinity.ts:212-215is correct: synchronous, no awaits, no race window with concurrentremember/setEntrycalls in single-threaded js. thesize === 0short-circuit is fine. coverage is intest/issue-474-affinity-invalidation.test.tsper the summary.one thing worth thinking about (not blocking):
writeVersionCounteris not reset, so post-clearremembercalls keep monotonically increasing versions. that's almost certainly what you want (forward-compat with stragglers), just call it out explicitly in the jsdoc so a future reader doesn't "fix" it.lib/routing-mutex.ts (1)
49-49: lgtm — type union extension.
lib/routing-mutex.ts:49mirrorsSwitchReasonSchemainlib/schemas.ts:114-121andManagedAccount.lastSwitchReasonper the summary. nothing else in this file needed to change.lib/codex-manager/commands/switch.ts (1)
64-83: lgtm — switch correctly drives the new pin/affinity contract.
lib/codex-manager/commands/switch.ts:64-71wiressetPin: true+bumpAffinityGeneration: true+switchReason: "manual", and:80extends the success line with(pinned for runtime routing)— matches the test expectations intest/codex-manager-switch-command.test.ts:81-94.two follow-ups already flagged elsewhere: (1) the duplicated
switchReasonunion literal vslib/codex-manager.ts:3186should be imported fromSwitchReasonSchemaonce; (2) if the user runsswitch <currently-active>, the "Switched to account N" wording is slightly misleading but the(pinned ...)suffix carries the actual intent, so leaving as-is.no concurrency or windows-fs concerns in this layer — those live one frame deeper in
persistAndSyncSelectedAccount.lib/storage/migrations.ts (1)
79-94: LGTM!lines 79-94 add well-documented optional fields
pinnedAccountIndexandaffinityGenerationtoAccountStorageV3. the comments clearly explain the pin contract ("MUST route exclusively"), generation bumping ("monotonically increasing counter bumped by user-initiated storage events"), and proxy behavior ("reads this from disk", "never bumps it from its own debounced writes"). migration at lines 101-134 correctly leaves the new fieldsundefinedfor backward compat.lib/storage.ts (2)
1312-1332: LGTM! synchronous read is justified for lost-update prevention.lines 1312-1332 add
readAffinityGenerationFromDiskusing synchronousreadFileSync(line 1315). while this blocks the event loop, the comment at lines 1302-1311 correctly explains the trade-off: callers re-read disk just before saving to computeMath.max(inMemory, disk) + 1, narrowing the lost-update window for concurrent CLI processes. the save itself is already serialized bywithStorageLock, so the sync read is the right choice for correctness. fallback to0on any error (lines 1329-1331) is safe.
1251-1284: validation logic is correct and tested.lines 1251-1284 validate
pinnedAccountIndexandaffinityGenerationinnormalizeAccountStorage. both fields are bounds-checked, finite/integer-validated, and dropped withlog.warnon invalid values.pinnedAccountIndexis clamped to[0, deduplicatedAccounts.length)(lines 1255-1259), andaffinityGenerationrequires non-negative integer (lines 1273-1276). test coverage is intest/issue-474-affinity-invalidation.test.tslines 73-112.lib/codex-manager/commands/unpin.ts (1)
45-51: LGTM! monotonic generation bump with retry wrapper.lines 45-50 implement
Math.max(inMemoryGeneration, diskGeneration) + 1to avoid lost updates when concurrent CLI processes bump the counter. the disk re-read viareadAffinityGenerationFromDisk(lines 45-47) narrows the lost-update window, and the serializedsaveAccountsWithRetry(line 51) handles EBUSY/EPERM retries per the codebase pattern. test coverage intest/issue-474-pin-safety.test.ts:71-96(EBUSY retry),test/issue-474-pin-safety.test.ts:276-306(disk ahead), andtest/issue-474-pin-safety.test.ts:308-322(fallback when getStoragePath missing).test/issue-474-pin-end-to-end.test.ts (1)
164-285: excellent e2e coverage with windows-aware filesystem delays.lines 164-285 validate end-to-end pin+affinity behavior through a real http server. the test:
- scenario 1 (lines 208-227): initial affinity selection
- scenario 2 (lines 229-251): pin override after generation bump, with
delay(50)at line 237 to settle FS on windows- scenario 3 (lines 253-283): hard-fail 503 when pinned account disabled, with
delay(50)at line 267the windows-aware delays are exactly right per coding guidelines. mocks only upstream fetch (lines 185-198), not real secrets. all assertions present, deterministic setup/teardown.
as per coding guidelines: "demand regression cases that reproduce windows filesystem behavior."
lib/accounts.ts (1)
253-259: ⚡ Quick wintest coverage for "manual" switch reason confirmed.
tests exist in
test/issue-474-pin-honored.test.ts:103andtest/codex-manager-switch-command.test.ts:85that validate the new reason. both assert persistence viaexpect(persist).toHaveBeenCalledWithwithswitchReason: "manual", confirming the change is properly covered. no issues found.test/issue-474-pin-safety.test.ts (1)
217-272: ⚖️ Poor tradeoffadd cross-command concurrency test to verify serialization across different operations.
test/issue-474-pin-safety.test.ts lines 217-272 covers concurrent unpin+unpin, but per coding guidelines ("demand regression cases that reproduce concurrency bugs"), tests must also verify that
withStorageLockserializes mutations across different command types. currently missing: concurrent unpin+switch or best+unpin. add a test that runs these operations concurrently against the same storage file and verifies the finalaffinityGenerationreflects all increments — this catches lost-update races when different CLI processes run different commands simultaneously.lib/codex-manager/commands/best.ts (1)
321-330: ⚡ Quick wintest coverage for best command pin clearing exists, but gaps remain.
test/issue-474-pin-honored.test.ts:114 covers clearing pin when switching from non-best to best, and test/issue-474-pin-honored.test.ts:187 covers suppressing the announcement when no prior pin. however, test/codex-manager-best-command.test.ts:152 ("prints json output when already on the best account") doesn't explicitly assert that persistAndSyncSelectedAccount is NOT called in the early-return path (lib/codex-manager/commands/best.ts:268-270).
also, bumpAffinityGeneration (lib/codex-manager/commands/best.ts:329) is passed to persist but not explicitly tested for its effect (should increment affinityGeneration). add a negative assertion to verify persist is skipped when already on best, and verify affinityGeneration increments.
test/issue-474-pin-honored.test.ts (2)
483-502: content-hash cache coverage looks solid.these two cases nail the relevant invariants — different bytes invalidate, identical bytes hit cache — and sidestep the windows mtime-granularity concern from the earlier review by hashing instead of trusting
mtimeMs. matches the snapshot keying inlib/runtime-rotation-proxy.ts:288-291,313-315.
294-463:chooseAccountpin-precedence coverage is comprehensive.rate-limit, cooldown, disabled, policy-blocked, out-of-range, and the no-
markSwitchedinvariant are all asserted. this is the critical behavioral contract for#474and the table of cases lines up with the guards atlib/runtime-rotation-proxy.ts:907-921. nothing to add here.lib/runtime-rotation-proxy.ts (6)
261-322: path-keyed, content-hashed snapshot is the right call.keying
STORAGE_META_CACHEby absolute storage path (lib/runtime-rotation-proxy.ts:288-291) plus a sha1 of the file bytes (lib/runtime-rotation-proxy.ts:313-315) cleanly fixes both prior review concerns: vitest workers no longer alias each other's snapshots, and rapid CLI bumps that share anmtimeMsbucket on coarse filesystems no longer poison the cache. the hashing cost on a sub-50KB file is negligible compared to the per-request fetch latency you're already paying. nice tradeoff.
324-390: transient-FS fallback semantics look correct, modulo the spin-wait flagged above.three retry attempts, treating
EBUSY/EPERM/EACCES/EAGAIN/SyntaxErroras transient, and preferring the last good cached snapshot over collapsing to "no pin, gen 0" on persistent transient failure — that is exactly what the cross-process atomic-rename window needs and addresses the prior "do not collapse to unpin + gen 0" review. the only correctness gap is the busy-wait between attempts (see comment on lines 370–378). otherwise the fallback shape atlib/runtime-rotation-proxy.ts:381-389is sound.
410-451: affinity invalidation seam + pin-aware persist short-circuit are clean.
maybeInvalidateAffinityFromDiskis a tidy seam (lib/runtime-rotation-proxy.ts:417-428) that lets unit tests assert the clear-on-bump behavior without spinning the http server, andpersistRuntimeActiveAccount's isPinned early return (lib/runtime-rotation-proxy.ts:436-442) is the right defense against the proxy clobbering a CLI-set pin viamarkSwitched/debounced save. the explicit// proxy MUST NOT clobber that pincomment will save the next reader some time.
881-921: pin precedence guards check the right invariants in the right order.
attemptedIndexesfirst (so a failed pinned attempt doesn't loop), then bounds, then policy block, then enabled, thenisAccountAvailableForFamily— this matches the test matrix intest/issue-474-pin-honored.test.ts:294-462and means a pinned-but-unhealthy account fails fast instead of silently rotating away from the user's choice. critically you also avoidmarkSwitchedon the pinned path, which is what kept the desktop app stuck pre-fix.
1252-1266: per-request meta read + affinity clear-on-bump is the correct shape.reading fresh meta per request (
lib/runtime-rotation-proxy.ts:1260), checking strictly-greater on generation, thenclearAll()and advancing the local watermark — this is exactly the cross-process invalidation contract#474needs, and because the proxy never bumps the generation itself, its own debounced disk writes can't trigger spurious clears. one tiny nit: this block runs even when no client request is actually inbound errors out earlier; that's fine, but if you ever add early-return paths above this, move the read down with the chooseAccount call so request budget isn't spent on disk IO for rejected requests.
1629-1646: 💤 Low valuehard-fail 503 on pinned-unavailable is the right user-visible contract.
codex_pinned_account_unavailablewith the explicit hint to runcodex-multi-auth status/codex-multi-auth unpinmatches the pr objective ("explicit hard-fail behavior") and avoids the "silently rotated to a different account" footgun that the desktop app would otherwise hit. one small thing:(pinnedIndex ?? 0) + 1at line 1640 —isPinnedalready impliespinnedIndexis a number, so the?? 0is dead but harmless. consider assertingpinnedIndex !== nullinstead so a future refactor doesn't accidentally produce an "account 1" message fornull.
…pin-wait (#474) P0-1: AccountManager.buildStorageSnapshot was dropping pinnedAccountIndex and affinityGeneration on every save. The proxy calls saveToDiskDebounced on every routine state change (rate-limit hits, cooldowns, near-quota refunds, refresh persistence), so each such save silently wiped any pin the user set via switch and reset the affinity counter. The persistRuntime ActiveAccount isPinned guard only covered the successful upstream path; many other call sites were unguarded. Fix: hydrate pin/gen onto AccountManager at construction time, refresh from disk just before snapshotting (race protection — a CLI switch/unpin between proxy startup and a routine save would otherwise be clobbered), and emit them in the snapshot. The pin is treated as part of the same atomic write that bumps the generation, so we only adopt the disk pin when the disk gen is strictly ahead of memory. P0-2: readStorageMetaFromDisk was busy-waiting up to 15ms per request between retries on transient FS errors. Replaced with a single read + mtime-cache short-circuit; on any failure (transient or otherwise) we fall back to the cached snapshot for that path and only return defaults on a true cache miss. Removes the no-op spin loop that blocked the event loop on the proxy hot path. Tests: extends issue-474-pin-safety with round-trip pin/gen save, the CLI-bumped-mid-save race, no-pin omission, and a first-ever-read EBUSY defaults case. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
|
@coderabbitai review The two remaining P1 findings flagged in the description-check have been fixed in
Tests cover (a) pin/gen round-trip through Gates: typecheck ✅, lint ✅, 3969/3969 tests passing. |
|
two things worth calling out before the review lands:
✅ Actions performedReview triggered.
|
Closes the remaining unresolved CodeRabbit review threads on PR #475: * docs/reference/commands.md: move the sticky-affinity blockquote out of the daily-use table so the `account ...` row is no longer orphaned by markdown's table-terminating blockquote. Document the new switch/unpin/best pin behavior under Upgrade Notes. * docs/reference/error-contracts.md: document the new HTTP 503 `codex_pinned_account_unavailable` error in the Runtime Rotation Proxy Error Contract table, with `pinnedAccountIndex` payload field. * lib/codex-manager/commands/status.ts: tighten the pin guard from `typeof === "number"` to `Number.isInteger`, rejecting NaN and non-integer pollution. Show the raw stored value (not stored+1) when the index is invalid so a corrupt -5 reads as "-5", not "-4". * lib/codex-manager/help.ts: note that `best` clears any manual pin set by `switch` so users see this on the canonical help screen. * lib/schemas.ts: introduce `PersistedSwitchReasonSchema` and the inferred `PersistedSwitchReason` type as the single source for the CLI persist switch-reason union. * lib/codex-manager.ts and lib/codex-manager/commands/switch.ts: import and reuse `PersistedSwitchReason` instead of re-declaring the literal union, eliminating the type-contract drift CodeRabbit flagged. Tests: - test/issue-474-pin-safety.test.ts: assertion now expects the raw stored value (e.g. "invalid account index 99" not "100"). Added regressions for negative pin (raw value displayed) and NaN pin (rejected without printing "Pinned: account NaN"). 3970 tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
|
@coderabbitai review All remaining unresolved review threads addressed in
Two threads are no longer actionable — leaving here for record:
Gates: typecheck ✅, lint ✅ (0 warnings), |
🧩 Analysis chain🏁 Script executed: Length of output: 29824 🏁 Script executed: Length of output: 13263
prior callouts from last round — both confirmed clean
bc48b20 changes — all confirmed
two non-actionable threads — rationale accepted
one minor observation (non-blocking)
triggering review over ✅ Actions performedReview triggered.
|
Closes the informational-only observation from CodeRabbit's bc48b20 review. The double-catch in AccountManager.buildStorageSnapshot already makes the regression provably impossible (defaults of {undefined, 0} fail the disk.affinityGeneration > effective guard, preserving the in-memory values), but explicit coverage here pins the contract so a future refactor of readPinAndGenFromDisk cannot silently throw. Adds 4 cases: - Missing file → defaults - Round-trip valid pin and generation - Partial-write JSON (simulates EBUSY mid atomic-rename) → defaults - Non-integer pin and negative generation rejected → defaults Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
|
Deep audit pass complete. Review thread state: all 20 review threads now resolved.
One non-blocking observation closed: added 4 explicit EBUSY cases for Final state of PR #475:
Ready for your manual smoke test on the real Codex desktop app — the 5 unchecked test-plan boxes in the PR body are the only outstanding items, and they require a live app session I can't run from here. Once those pass, the PR is ready to flip from draft to ready-for-review. |
|
@greptileai review Triggering a fresh Greptile review pass over the latest 5 commits since the original review (e8f8ab5..3275287). Branch is at 9 commits with 3974/3974 tests passing and all 20 prior review threads resolved. |
…mps (#474) Address PR #475 review feedback: - unpin.ts now wraps saveAccounts via saveAccountsWithRetry so a transient EBUSY/EPERM (common on Windows when the proxy briefly holds the file) no longer silently loses the user's clear-pin intent. - runtime-rotation-proxy STORAGE_META_CACHE is now keyed by absolute storage path (Map) so concurrent vitest workers and multiple proxy instances pointing at different files cannot corrupt each other. - readStorageMetaFromDisk now treats EBUSY/EPERM/EACCES/EAGAIN and JSON parse errors (likely partial reads mid atomic-rename) as transient: it retries up to 3 times with tiny backoff, then falls back to the last cached snapshot for that path. Returning defaults on a transient error would have falsely reported "no pin, no affinity bump" and let the proxy use the wrong account. - persistAndSyncSelectedAccount and runUnpinCommand now re-read the on-disk affinityGeneration just before saving and apply Math.max(inMemory, disk) + 1 so concurrent CLI processes cannot lose increments via a load+mutate lost update. Helper readAffinityGenerationFromDisk lives in storage.ts. - status.ts bounds-checks pinnedAccountIndex against storage.accounts.length and surfaces an actionable "run codex-multi-auth unpin" hint when the pin is out of range.
…umps (#474) Add issue-474-pin-safety.test.ts covering the PR #475 review fixes: - unpin retries on transient EBUSY via saveAccountsWithRetry - STORAGE_META_CACHE per-path isolation (no cross-contamination across concurrent vitest workers / multiple proxies) - readStorageMetaFromDisk preserves the last cached snapshot on a partial-write parse error mid atomic-rename, and falls back to defaults only when no cache exists - status reports "invalid account index … run codex-multi-auth unpin" for out-of-range pins (positive and negative) - unpin uses Math.max(inMemory, disk) + 1 when the on-disk generation is ahead of the loaded snapshot, and gracefully falls back to inMemory + 1 when no getStoragePath dep is supplied - Two concurrent unpin processes against the same storage file converge to >= initialGen + 2 (no lost increments)
Closes the remaining unresolved CodeRabbit review threads on PR #475: * docs/reference/commands.md: move the sticky-affinity blockquote out of the daily-use table so the `account ...` row is no longer orphaned by markdown's table-terminating blockquote. Document the new switch/unpin/best pin behavior under Upgrade Notes. * docs/reference/error-contracts.md: document the new HTTP 503 `codex_pinned_account_unavailable` error in the Runtime Rotation Proxy Error Contract table, with `pinnedAccountIndex` payload field. * lib/codex-manager/commands/status.ts: tighten the pin guard from `typeof === "number"` to `Number.isInteger`, rejecting NaN and non-integer pollution. Show the raw stored value (not stored+1) when the index is invalid so a corrupt -5 reads as "-5", not "-4". * lib/codex-manager/help.ts: note that `best` clears any manual pin set by `switch` so users see this on the canonical help screen. * lib/schemas.ts: introduce `PersistedSwitchReasonSchema` and the inferred `PersistedSwitchReason` type as the single source for the CLI persist switch-reason union. * lib/codex-manager.ts and lib/codex-manager/commands/switch.ts: import and reuse `PersistedSwitchReason` instead of re-declaring the literal union, eliminating the type-contract drift CodeRabbit flagged. Tests: - test/issue-474-pin-safety.test.ts: assertion now expects the raw stored value (e.g. "invalid account index 99" not "100"). Added regressions for negative pin (raw value displayed) and NaN pin (rejected without printing "Pinned: account NaN"). 3970 tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Closes #474.
What the user reported
codex-multi-auth switch <n>worked for the Codex CLI but the Codex desktop app continued routing through a different account. After investigation the issue has two distinct halves, both of which this PR fixes in one cohesive change.Half 1 — Manual switch never reached the desktop app
The desktop app routes through
lib/runtime-rotation-proxy.ts. The proxy:storage.activeIndex.markSwitched("rotation").Result:
switch <n>was a no-op for the app path.Half 2 — Session affinity locked the app to one account
Default-on session affinity (
config.ts:191-192,sessionAffinity: true, 20-min TTL) plus the Codex desktop app chaining turns viaprevious_response_id(which the proxy uses as a stable session key —runtime-rotation-proxy.ts:resolveSessionKey) glues every chat turn to whichever account first responded. Affinity was only forgotten on 429, near-quota, or stream-error — not on user intent.Result: even if Half 1 had been fixed in isolation, an
unpinorbestwould still leave the app stuck on the prior account for up to 20 minutes.What this PR does
A. Pinned-account contract (Half 1)
switch <n>pinnedAccountIndex; proxy routes app traffic to that accountbestunpinpinnedAccountIndexso rotation resumescodex_pinned_account_unavailableB. Session-affinity invalidation (Half 2)
affinityGenerationcounter onAccountStorageV3.switch,unpin, andbestbump it before the disk write.SessionAffinityStoreexposesclearAll()(preserves config, drops entries).affinityGenerationalongside the pin via a content-hash-keyed per-path cache. OnhandleRequest, beforechooseAccount, if the disk generation is newer than the proxy has cached, it callsclearAll()so the same request benefits.C. Hardening (from review feedback)
runtime-rotation-proxy.ts— replaces the original mtime-only cache so Windows sub-millisecond mtime granularity, atomic-rename mid-flight, and per-test temp paths cannot share or stale-serve a snapshot.readStorageMetaFromDiskno longer blocks the event loop withwhile (Date.now() < deadline). Single read + cached fallback; transient errors (EBUSY/EPERM/EACCES/EAGAIN/SyntaxError) preserve the last cached snapshot rather than falling through to defaults.AccountManager.buildStorageSnapshotnow persists pin/gen — earlier snapshot omitted these fields, so every routine debounced save (rate-limit, cooldown, etc.) silently wiped the user's pin. Snapshot now refreshes pin/gen from disk just before serializing (race-safe: disk pin is adopted only when diskaffinityGenerationstrictly exceeds memory, matching the CLI's bump-then-write contract).affinityGenerationincrements —persistAndSyncSelectedAccountandunpinre-read disk gen just before save and useMath.max(inMemory, disk) + 1so concurrent CLI processes never lose an invalidation signal.unpinusessaveAccountsWithRetry— matches every other mutation path; absorbs Windows EBUSY/EPERM transient writer contention.Number.isIntegerguard — invalid pin (negative, NaN, out-of-range) prints raw stored value withunpinremediation hint instead of "Pinned: account NaN" orvalue+1.PersistedSwitchReasonSchemainlib/schemas.tsis the single source for the CLI persist switch-reason union;codex-manager.tsandcommands/switch.tsnow import it instead of re-declaring the literal union.Tests
3974 tests passing across 267 test files. New behavioral coverage:
test/issue-474-pin-honored.test.ts— 20 cases (pin write/clear, chooseAccount priority, no-clobber, cache invalidation, status output).test/issue-474-affinity-invalidation.test.ts— 19 cases (gen increment, normalization, clearAll, mtime/hash cache, end-to-end invalidation flow).test/issue-474-pin-end-to-end.test.ts— realhttp.Server+http.request; pin written mid-flight; second request lands on the pinned account; pinning to a disabled account returns HTTP 503codex_pinned_account_unavailable.test/issue-474-pin-safety.test.ts— 19 cases (unpin EBUSY retry, per-path cache isolation, transient-FS preserves cache, status bounds-check + NaN, monotonic concurrent gen bumps, buildStorageSnapshot round-trip + race protection, direct EBUSY coverage forreadPinAndGenFromDisk).Test plan
npm run typecheck— greennpm run lint— green (0 warnings)npm test— 3974 tests passingcodex-multi-auth switch 1→ desktop app routes to account 1 even mid-conversationcodex_pinned_account_unavailablecodex-multi-auth unpin→ next desktop-app turn rotates via hybrid scoringcodex-multi-auth best→ pin cleared, affinity dropped, rotation can resumeatomicWriteFilerename is observed by the content-hash cacheCommits
2824525fix(runtime): honor manual switch as pinned account in proxyd045b58docs: document manual pin and unpin commandcac0b3ffix(runtime): invalidate session affinity on switch/unpin/beste8f8ab5test(runtime): add end-to-end HTTP coverage and content-hash cache2958e45fix(runtime): harden pin/unpin against transient FS and concurrent bumpsdf5c0b2test(runtime): cover pin/unpin transient FS, per-path cache, atomic bumps4593ec8fix(runtime): preserve pin/gen on routine saves and remove hot-path spin-waitbc48b20fix: address remaining PR review nitpicks3275287test(storage): direct EBUSY coverage forreadPinAndGenFromDiskReview state
bc48b20confirmsrationale acceptedfor the two non-actionable threads.🤖 Generated with Claude Code
note: greptile review for oc-chatgpt-multi-auth. cite files like
lib/foo.ts:123. confirm regression tests + windows concurrency/token redaction coverage.Greptile Summary
this PR fixes two independent bugs (#474): the runtime proxy ignored
switch <n>entirely, and session affinity locked the desktop app to the first-responding account for up to 20 minutes even after a manual switch. both halves are fixed together via apinnedAccountIndexfield written by cli commands, a monotonically-increasingaffinityGenerationcounter that the proxy observes per-request, and a content-hash-keyed per-path disk-meta cache that replaces the old spin-wait.switch <n>now writespinnedAccountIndexto storage; proxy reads it on every request and either routes exclusively to that account or hard-fails 503 (codex_pinned_account_unavailable);bestand the newunpincommand clear the pin.affinityGenerationbefore the disk write; proxy callsclearAll()on theSessionAffinityStorewhen it detects a newer generation, so mid-conversation stickiness is broken immediately on user intent.buildStorageSnapshotnow refreshes pin/gen from disk before every serialization,persistAndSyncSelectedAccountguards the proxy's debounced saves from clobbering the cli-set pin, andunpinusessaveAccountsWithRetryfor windows EBUSY safety.Confidence Score: 5/5
safe to merge — both halves of the bug are fixed, the save/pin clobber path is correctly guarded, and windows transient-write safety is maintained throughout
the pin/affinity logic is consistent across all write paths: cli commands use saveAccountsWithRetry with Math.max gen increments, buildStorageSnapshot refreshes pin/gen from disk before every serialization, and the proxy's persistRuntimeActiveAccount early-returns on isPinned so debounced error-path saves cannot clobber the cli-set pin. the content-hash cache in the proxy correctly isolates per-path state for concurrent vitest workers. the 503 hard-fail on unavailable pinned account is the right contract — no silent fall-through. no oauth, token-refresh, or wire-format changes.
no files require special attention
Important Files Changed
Sequence Diagram
sequenceDiagram participant CLI as codex-multi-auth switch/unpin/best participant Disk as accounts.json participant Proxy as runtime-rotation-proxy participant Affinity as SessionAffinityStore participant Upstream as OpenAI API CLI->>Disk: write pinnedAccountIndex + bump affinityGeneration Note over CLI,Disk: saveAccountsWithRetry (Math.max lock-safe gen) Proxy->>Disk: readStorageMetaFromDisk() [content-hash cached] Disk-->>Proxy: "{ pinnedAccountIndex, affinityGeneration }" alt "affinityGeneration > lastObserved" Proxy->>Affinity: clearAll() Proxy->>Proxy: "lastObservedAffinityGeneration = new gen" end alt pinnedAccountIndex set Proxy->>Proxy: chooseAccount(pinnedIndex) alt pinned account available Proxy->>Upstream: forward request Upstream-->>Proxy: response Proxy->>Proxy: "persistRuntimeActiveAccount(isPinned=true) no-op" else pinned account unavailable Proxy-->>CLI: 503 codex_pinned_account_unavailable end else no pin Proxy->>Affinity: getPreferredAccountIndex Proxy->>Upstream: forward via hybrid scoring end Proxy->>Disk: "saveToDiskDebounced -> buildStorageSnapshot" Note over Proxy,Disk: readPinAndGenFromDisk inside snapshot preserves CLI-written pin/genReviews (7): Last reviewed commit: "test(storage): direct EBUSY coverage for..." | Re-trigger Greptile