Skip to content

feat: auto-rotate on disabled/expired workspace errors#136

Merged
ndycode merged 12 commits into
ndycode:mainfrom
Microck:feat/workspace-disabled-auto-rotate
Mar 20, 2026
Merged

feat: auto-rotate on disabled/expired workspace errors#136
ndycode merged 12 commits into
ndycode:mainfrom
Microck:feat/workspace-disabled-auto-rotate

Conversation

@Microck
Copy link
Copy Markdown
Contributor

@Microck Microck commented Mar 20, 2026

Summary

Fixes the remaining workspace-disabled review follow-ups.

What changed

  • narrowed workspace-disabled message matching so billing and generic team/service-account messages stay on the entitlement path
  • kept the workspace-less terminal path explicit by returning the current 403 immediately instead of falling through to recovery handling
  • limited workspace rotation scans to successor workspaces only
  • added regressions for billing-style false positives and the single-account workspace-less 403 path

Validation

  • npx vitest run test/fetch-helpers.test.ts test/index-retry.test.ts test/accounts.test.ts
  • npm run lint
  • npm run typecheck
  • npm run build
  • npm test

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 lands auto-rotation on workspace-disabled/expired 403s, correctly re-issues the request with rebuilt headers against the next workspace within the same account before falling back to another account. all five issues flagged in the previous review round are addressed: the entitlement cache is guarded with !isDisabledWorkspaceError, attempted.delete(account.index) precedes the rotation continue, the exhausted-account path uses continue accountAttemptLoop instead of break, disableCurrentWorkspace guards against double-disable with an expectedWorkspaceId check plus an enabled === false early-return, and re-enabling an account now resets all workspace flags to prevent an infinite-disable cycle.

key findings:

  • mock divergence in rotateToNextWorkspace (test/index-retry.test.ts:230): the mock iterates offset <= workspaces.length (inclusive) vs the real i < totalWorkspaces (exclusive), meaning it revisits the current workspace index on the final pass; harmless today because the disabled workspace is filtered by enabled !== false, but it diverges from the real implementation and could mask concurrency edge cases where rotateToNextWorkspace is called before disableCurrentWorkspace completes
  • regex haystack spans code and body (lib/request/fetch-helpers.ts:278): normalizedCode and bodyText are joined with a space before pattern matching, so a short code like "workspace" plus a body containing an unrelated "disabled" word could produce a false positive; the explicit workspaceErrorCodes set is the correct gate for string codes and should be applied before pattern matching
  • missing vitest coverage: no test exercises the single-account, workspace-less path that reaches return errorResponse directly (line 1998 of index.ts); worth adding to ensure the fallback contract is locked down
  • workspace persistence, merge logic, and currentWorkspaceIndex initialisation are all covered by well-structured tests; the cloneMockAccount helper correctly prevents deep-state leakage across test cases

Confidence Score: 4/5

  • safe to merge with the mock off-by-one fixed; core rotation logic is correct and all prior issues are resolved
  • the main rotation loop, entitlement cache guard, workspace persistence, and re-enable reset are all correct and well-tested. the one actionable p1 is a test mock divergence that doesn't affect production code. the p2 haystack construction issue is a latent false-positive risk but requires an unusual combination of inputs. no windows filesystem or token safety regressions introduced.
  • test/index-retry.test.ts (mock off-by-one) and lib/request/fetch-helpers.ts (haystack construction)

Important Files Changed

Filename Overview
index.ts core rotation logic is correct — attempted.delete before continue, !isDisabledWorkspaceError guard on entitlement cache, and accountAttemptLoop label all address prior review findings; minor coverage gap in single-account workspace-less exhaustion path
lib/accounts.ts workspace management methods are well-structured; disableCurrentWorkspace guards against double-disable via expectedWorkspaceId check and enabled === false early-return; resetWorkspaces called on re-enable correctly fixes the infinite-disable cycle
lib/request/fetch-helpers.ts status guard is correct and test coverage is thorough; haystack construction merges normalizedCode and bodyText so regex patterns can match across the boundary, risking false positives for generic short codes paired with unrelated body text
lib/storage/migrations.ts minimal change — adds workspaces and currentWorkspaceIndex fields to AccountMetadataV3, no migration logic needed since both fields are optional and backwards-compatible
test/accounts.test.ts three new unit tests cover double-rotate guard, re-enable workspace reset with default, and re-enable without default; all well-structured with realistic stored state fixtures
test/fetch-helpers.test.ts 17 cases cover status guard, numeric codes, wrapped tokens, and billing false-positives; missing a test where a string code like "workspace" is combined with an unrelated "disabled" body word to validate the cross-boundary regex behaviour
test/index-retry.test.ts four new integration tests cover workspace rotation retry, exhausted-account fallback, workspace-less no-disable, and workspace-less fallback; mock rotateToNextWorkspace uses offset <= workspaces.length (inclusive) diverging from the real i < totalWorkspaces (exclusive), which can mask edge cases where rotation is called without a prior disable
test/index.test.ts five new persistence tests cover initial workspace index selection, tracked-state preservation on refresh, disabledAt merge, workspace removal index clamping, and reorder tracking; cloneMockAccount correctly deep-clones workspace arrays to prevent test state leakage

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Request → accountAttemptLoop] --> B[getAccount]
    B --> C[Fetch with workspaceId or accountId]
    C --> D{Response}
    D -->|2xx| E[Return success]
    D -->|403| F[isWorkspaceDisabledError?]
    F -->|No| G[entitlementCache.markBlocked\ncapabilityPolicyStore.recordFailure]
    G --> H[Other error handling]
    F -->|Yes - no tracked workspaces| I{hasRemainingAccounts?}
    I -->|Yes| J[continue accountAttemptLoop\naccount stays enabled]
    I -->|No| K[return errorResponse]
    F -->|Yes - has workspaces| L[disableCurrentWorkspace\nexpectedId guard + enabled===false guard]
    L --> M[rotateToNextWorkspace\nlinear scan i=1..totalWorkspaces-1]
    M -->|nextWorkspace found| N[saveToDiskDebounced\nshowToast\nattempted.delete\ncontinue accountAttemptLoop]
    M -->|null - all exhausted| O[setAccountEnabled false\nsaveToDiskDebounced\nshowToast\nforgetSession\ncontinue accountAttemptLoop]
    N --> B
    J --> B
    O --> B
    B -->|attempted.size >= accountCount| P[return errorResponse]
Loading

Comments Outside Diff (1)

  1. test/index-retry.test.ts, line 230 (link)

    mock rotateToNextWorkspace iterates one slot too many

    the mock uses offset <= workspaces.length while the real implementation at lib/accounts.ts:903 uses i < totalWorkspaces. the mock's final iteration computes nextIndex = (currentIdx + workspaces.length) % workspaces.length = currentIdx, revisiting the workspace that was just disabled. the results are identical in every current test because the disabled workspace has enabled === false, but the divergence means a test where rotateToNextWorkspace is invoked without a prior disableCurrentWorkspace (e.g. the fallback path on line 2015 of index.ts) would silently allow the mock to return the current workspace while the real impl would not. concurrency note: if two requests race on the same account, one may call rotateToNextWorkspace before the other's disableCurrentWorkspace runs; the mock would return the stale workspace, masking that bug in tests.

Prompt To Fix All With AI
This is a comment left during a code review.
Path: test/index-retry.test.ts
Line: 230

Comment:
**mock `rotateToNextWorkspace` iterates one slot too many**

the mock uses `offset <= workspaces.length` while the real implementation at `lib/accounts.ts:903` uses `i < totalWorkspaces`. the mock's final iteration computes `nextIndex = (currentIdx + workspaces.length) % workspaces.length = currentIdx`, revisiting the workspace that was just disabled. the results are identical in every current test because the disabled workspace has `enabled === false`, but the divergence means a test where `rotateToNextWorkspace` is invoked *without* a prior `disableCurrentWorkspace` (e.g. the fallback path on line 2015 of index.ts) would silently allow the mock to return the current workspace while the real impl would not. concurrency note: if two requests race on the same account, one may call `rotateToNextWorkspace` before the other's `disableCurrentWorkspace` runs; the mock would return the stale workspace, masking that bug in tests.

```suggestion
			for (let offset = 1; offset < workspaces.length; offset += 1) {
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: lib/request/fetch-helpers.ts
Line: 278

Comment:
**regex patterns can match across code/body boundary in `haystack`**

`haystack` concatenates `normalizedCode` and `bodyText` with a single space, so pattern 1 (`workspace.*disabled`) will match if the error code is `"workspace"` and the body text independently contains any of `disabled|expired|deactivated|terminated` (e.g. `"workspace" + "This feature has been disabled"`). the `workspaceErrorCodes` set-based check below is the correct guard for string codes; running the regex patterns on the merged haystack before that check means a short, generic code like `"workspace"` or `"workspace_access"` paired with an unrelated body word triggers a false positive.

consider restricting pattern matching to `bodyText` only and keeping the explicit code set as the sole path for string codes:

```typescript
// Only match patterns against the body text
const bodyHaystack = bodyText.toLowerCase();
for (const pattern of disabledPatterns) {
    if (pattern.test(bodyHaystack)) {
        return true;
    }
}
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: index.ts
Line: 1990-1999

Comment:
**workspace-less fallback skips `runtimeMetrics.failedRequests++` increment path and has no vitest coverage for single-account exhaustion**

for the workspace-less branch on line 1990, when `!hasRemainingAccounts` the function returns `errorResponse` directly. this path has no test covering single-account workspace-less exhaustion: the existing `"does not disable workspace-less accounts"` test has `accountSelections = [account]` (one account, loop exits normally), but there's no test where a workspace-less single account is the *only* account, receives a workspace-disabled 403, and the plugin is expected to return the 403 directly to the caller. add a regression for that path to ensure `runtimeMetrics` and the return value are both correct.

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: "Tighten workspace-di..."

Microck added 2 commits March 17, 2026 18:45
…witching

Adds a new CLI command that automatically switches to the best standing account
based on the forecast algorithm. Supports --live, --json, and --model flags.

- Add 'best' to AUTH_SUBCOMMANDS in codex-routing.js
- Implement runBest() function in codex-manager.ts
- Add 'best' switch reason to schemas and types
- Update help text and feature list

Usage: codex auth best [--live] [--json] [--model <model>]
@Microck Microck requested a review from ndycode as a code owner March 20, 2026 04:29
@chatgpt-codex-connector
Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 20, 2026

Important

Review skipped

Auto reviews are limited based on label configuration.

🚫 Review skipped — only excluded labels are configured. (1)
  • skip-review

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 654d83d5-55f6-4228-b863-94848c854f37

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

this pr introduces multi-workspace support to oauth token refresh and request retry logic. accounts now track workspace metadata with enabled/disabled states and current workspace index. on detecting workspace-disabled 403 errors, the request handler rotates to alternative workspaces within the same account before retrying, falling back to other accounts if exhausted.

Changes

Cohort / File(s) Summary
Workspace Data Model
lib/accounts.ts, lib/storage/migrations.ts
Added Workspace interface (id, name, enabled, disabledAt, isDefault). Extended ManagedAccount with workspaces[] and currentWorkspaceIndex. Added AccountManager methods: setWorkspaces(), getCurrentWorkspace(), disableCurrentWorkspace(), rotateToNextWorkspace(), plus helpers hasEnabledWorkspaces(), getWorkspaceCount(), getEnabledWorkspaceCount(). Updated serialization/hydration in constructor and saveToDisk().
OAuth & Request Handling
index.ts, lib/request/fetch-helpers.ts
Extended TokenSuccessWithAccount to carry workspaces[]. Updated token resolution to populate workspaces array. Refactored 403 handling to detect workspace-disabled errors via new isWorkspaceDisabledError() predicate, rotate workspaces within account, retry, or disable account and continue. Introduced explicit accountAttemptLoop label for control flow. Added workspace-aware request headers (prefer workspaceId over accountId).
Workspace Error Detection
lib/request/fetch-helpers.ts
New isWorkspaceDisabledError(status, code, bodyText) function: checks 403 status and tests code/message against patterns for disabled/expired/removed workspace or account. Note: malformed arrow function in token normalization will prevent compilation (token.trim()) should be .map(...).filter(...)).
Core Request & Retry Tests
test/index-retry.test.ts
Refactored account/workspace mock state into shared accountManagerState. Updated fetch-helpers mock to emit x-account-id header, parse error body as JSON, and stub isWorkspaceDisabledError. Added getCurrentOrNextForFamily() to shift from accountSelections queue. Added 3 new tests validating workspace rotation on 403 disabled-workspace, disabled workspace exhaustion, and no-op for accounts without workspaces.
Account & Workspace Tests
test/accounts.test.ts, test/index.test.ts, test/fetch-helpers.test.ts
Added 3 account tests for disableCurrentWorkspace() with mismatched IDs and setAccountEnabled() re-enabling exhausted accounts. Extended index.test.ts with workspace-metadata shape and 4 new persistAccountPool tests validating workspace preservation during refresh (no refresh, merge, removal, reordering). Added comprehensive isWorkspaceDisabledError test suite (65 lines) covering status/code/message combos and false positives.

Sequence Diagram

sequenceDiagram
    participant Client as Request Handler
    participant AM as AccountManager
    participant FH as Fetch Helpers
    
    rect rgba(200, 150, 100, 0.5)
    Note over Client,FH: Workspace Rotation Flow (403 Disabled)
    Client->>FH: handleErrorResponse(response)
    FH->>FH: isWorkspaceDisabledError(403, code, body)
    FH-->>Client: { response, errorBody }
    Client->>AM: rotateToNextWorkspace(account)
    AM-->>Client: Workspace | null
    alt Workspace available
        Client->>Client: Rebuild headers with new workspaceId
        Client->>Client: Retry request
    else Exhausted
        Client->>AM: disableCurrentWorkspace(account)
        Client->>AM: setAccountEnabled(account, false)
        Client->>Client: Continue to next account
    end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes


Senior Review Notes

critical blocker: lib/request/fetch-helpers.ts:line ~45 has malformed arrow function syntax in token normalization—.map((token) => token.trim()) is written as token.trim()), preventing compilation. this needs immediate fix before merge.

state management risks:

  • lib/accounts.ts:setAccountEnabled(), disableCurrentWorkspace(), rotateToNextWorkspace() have no concurrency guards. if called simultaneously from multiple retries or event handlers, index calculations could corrupt (e.g., currentWorkspaceIndex pointing past valid range after rotation). recommend atomic operations or defensive bounds checks.
  • workspace enabled state and disabledAt timestamps lack clarification on semantics: is disabledAt cleared when re-enabling? does the refresh merge logic preserve stale timestamps correctly when workspaces are removed server-side?

logic review gaps:

  • index.ts:resolveAccountSelection() converts candidates to workspaces array but test coverage (test/index-retry.test.ts, test/index.test.ts) doesn't validate empty or single-workspace scenarios, nor does it cover the fallback from currentWorkspaceIndex to default/first-enabled logic across all permutations.
  • workspace merge during refresh (index.ts post-refresh persistence) preserves enabled/disabledAt by matching id, but if server reorders workspaces or ids change, the logic silently falls through without reconciliation warnings.
  • test/index-retry.test.ts lacks tests for workspace rotation with 0 enabled workspaces (should immediately disable account) or when refresh clears workspaces[] on legacy OAuth responses (backward compatibility).

missing regression coverage:

  • windows path handling: accounts serialization via saveToDisk() not tested on windows (potential path separators in workspace ids if user-provided).
  • concurrency under rapid 403s: multiple retries firing disableCurrentWorkspace() / rotateToNextWorkspace() in parallel; test setup uses synchronous mocks, won't catch async race conditions.
  • edge case: currentWorkspaceIndex recalculation logic after server removes the active workspace by id (test/index.test.ts covers this for refresh but not for in-flight 403 rotation sequences).

test quality observations:

  • test/index-retry.test.ts mocks are tightly coupled to implementation details (e.g., accountManagerState), reducing robustness against refactors. consider integration-style test that uses real AccountManager class.
  • new isWorkspaceDisabledError test suite is thorough but doesn't validate response body text parsing when code is missing or garbled (edge case where malformed json coexists with workspace keywords).
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed title matches required format (feat: lowercase summary ≤72 chars) and accurately describes the main change: workspace rotation on disabled/expired errors.
Description check ✅ Passed the pr description covers all required template sections with complete validation steps and clear summary of workspace-rotation changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
✨ Simplify code
  • Create PR with simplified code
📝 Coding Plan
  • Generate coding plan for human review comments

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread lib/request/fetch-helpers.ts Outdated
Comment thread index.ts Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/storage/migrations.ts (1)

28-53: ⚠️ Potential issue | 🟡 Minor

update the disk reader before persisting "best".

lib/storage/migrations.ts:28-53 and lib/schemas.ts:85 now accept "best", but lib/storage.ts:1829-1862 still strips anything outside "rate-limit" | "initial" | "rotation" on load. that means a migrated or newly saved "best" value round-trips back to undefined after the next reload. please update that validator and add a small round-trip vitest for the storage path.

As per coding guidelines, lib/**: 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.

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@index.ts`:
- Around line 1968-1970: The warning currently emits raw account.email in
logWarn (and passes the same raw text in the structured payload); remove any
direct use of account.email in that log call and instead log only the account
index (account.index + 1) or a masked identifier (e.g., last N chars or a masked
suffix produced by a helper like maskEmailSuffix) and ensure the structured
payload does not contain the unmasked email or full upstream text; update the
logWarn invocation that references account.email so it only includes the safe
identifier and keep errorCode/errorMessage but scrub any embedded PII before
passing them.

In `@lib/codex-manager.ts`:
- Around line 4123-4148: When queuedRefresh(bestAccount.refreshToken) returns a
non-"success" result, add a warning log similar to runSwitch's behavior instead
of silently continuing: detect refreshResult.type !== "success" after the
queuedRefresh call in the block guarding hasUsableAccessToken, and call
console.warn with a message containing bestAccount (or parsed identifier) and
normalizeFailureDetail(refreshResult.message, refreshResult.reason) so callers
are informed the silent refresh failed before sync; keep existing success-path
assignments to syncAccessToken/syncRefreshToken/syncExpiresAt/syncIdToken
unchanged.
- Around line 4105-4162: There is duplicated account-switching logic between the
block starting at the duplicated section and runSwitch; extract a shared helper
(e.g., performAccountSwitch(storage, targetIndex, reason)) that encapsulates:
setting storage.activeIndex and storage.activeIndexByFamily (MODEL_FAMILIES),
enabling the account if disabled, refreshing tokens via hasUsableAccessToken +
queuedRefresh, syncing fields on bestAccount (accessToken, refreshToken,
expiresAt, email, accountId, accountIdSource, lastUsed, lastSwitchReason),
calling saveAccounts(storage), and invoking setCodexCliActiveSelection with the
final tokens and idToken; replace both the current block and the runSwitch logic
(the code around runSwitch) to call performAccountSwitch with appropriate
arguments (targetIndex, "best" or other reason) to ensure a single source of
truth.
- Around line 4024-4055: The for-loop iterating storage.accounts is indented one
level too deep creating inconsistent formatting; re-indent the entire loop block
starting at the for (let i = 0; i < storage.accounts.length; i += 1) line so its
body aligns with surrounding code, preserving all logic (checks using
account.enabled, hasUsableAccessToken(account, now),
queuedRefresh(account.refreshToken), extractAccountId, fetchCodexQuotaSnapshot,
and updates to liveQuotaByIndex and refreshFailures) but removing the extra
leading indentation introduced by the copy-paste.
- Around line 3981-4180: The runBest CLI flow (function runBest) lacks
integration test coverage and callers of queuedRefresh need verification for
429/EBUSY transient handling; add a new integration test file
test/codex-manager-cli.test.ts that exercises runBest end-to-end (live probing
via fetchCodexQuotaSnapshot, handling refresh failures, re-enabling disabled
accounts, switching/sync behavior via setCodexCliActiveSelection, JSON and human
outputs, and cases where no recommendation is available) and assert proper
outputs and account state updates; separately, update
refresh-queue.ts/queuedRefresh to explicitly handle 429 and EBUSY transient
errors with retry/backoff and ensure callers (runBest and other queuedRefresh
callers) correctly interpret transient vs permanent failures (TokenFailure), and
add unit tests that simulate 429/EBUSY to validate deduplication, lease
coordination, and backoff behavior.

In `@lib/request/fetch-helpers.ts`:
- Around line 268-309: The isWorkspaceDisabledError function currently treats
numeric/non-string codes and overly-broad billing/payment messages as
terminal—fix by first normalizing the code safely (e.g., const normCode = typeof
code === "string" ? code.toLowerCase() : "") and use exact-match or full-token
checks (not substring includes) against a tightened workspaceErrorCodes list
(e.g., match "workspace_disabled", "workspace_expired", "workspace_terminated",
"account_disabled", "account_expired", "organization_disabled" only); remove or
narrow generic billing/payment entries (or require they co-occur with
"workspace" or "account" in haystack) and tighten regexes in disabledPatterns to
avoid matching plan/billing notices; update tests in test/fetch-helpers.test.ts
to cover numeric codes and billing 403 scenarios and ensure no regressions.

In `@test/fetch-helpers.test.ts`:
- Around line 316-359: Add a deterministic vitest that exercises the actual
auto-rotate path (not just isWorkspaceDisabledError): simulate at least two
accounts, trigger a 403+workspace-disabled response so the code path in index.ts
(the auto-rotate/rotation loop around lines ~1958-1986) marks the current
account enabled === false, goes through the debounced persistence path (the
save/persist debounced function used by the rotation logic), clears session
affinity, and then attempts the next account; assert that enabled === false was
persisted and that the rotation moved to the next account. Use vitest
timers/promises to control debounce and make the test deterministic (do not mock
real secrets, ensure assertions are present).
🪄 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: 8de9776b-930e-428d-8c93-315ed87c20a2

📥 Commits

Reviewing files that changed from the base of the PR and between b918aac and 4a31dca.

📒 Files selected for processing (10)
  • index.ts
  • lib/accounts.ts
  • lib/codex-manager.ts
  • lib/request/fetch-helpers.ts
  • lib/schemas.ts
  • lib/storage/migrations.ts
  • scripts/codex-multi-auth.js
  • scripts/codex-routing.js
  • scripts/codex.js
  • test/fetch-helpers.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 (2)
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/accounts.ts
  • lib/schemas.ts
  • lib/storage/migrations.ts
  • lib/request/fetch-helpers.ts
  • lib/codex-manager.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/fetch-helpers.test.ts
🔇 Additional comments (4)
lib/codex-manager.ts (4)

295-295: lgtm - help text accurately reflects the exposed flags.

the command always uses live probing internally, so not exposing --live is a reasonable simplification.


357-357: lgtm - feature entry looks good.


4301-4303: lgtm - dispatch wiring follows existing patterns.


4152-4152: "best" is a valid lastSwitchReason value — confirmed in lib/schemas.ts:85 and lib/accounts.ts:94. the value is explicitly included in the union type and zod schema alongside "rate-limit", "initial", and "rotation".

Comment thread index.ts Outdated
Comment thread lib/codex-manager.ts
Comment thread lib/codex-manager.ts
Comment thread lib/codex-manager.ts Outdated
Comment thread lib/codex-manager.ts Outdated
Comment thread lib/request/fetch-helpers.ts Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@index.ts`:
- Around line 2000-2001: The call to
accountManager.disableCurrentWorkspace(account) can redundantly disable a
workspace if another request already did so; update disableCurrentWorkspace to
first check the current workspace's enabled flag and return early (or return a
boolean) when it's already false, perform the state change and persistence only
if enabled was true, and ensure rotateToNextWorkspace() is invoked only when
disableCurrentWorkspace actually changed state (or make rotateToNextWorkspace
resilient to a no-op); reference the disableCurrentWorkspace and
rotateToNextWorkspace methods and the account/accountManager interaction when
applying the change.
- Around line 1982-2039: Concurrent requests can race when mutating workspace
state via disableCurrentWorkspace(account) and rotateToNextWorkspace(account) on
a shared ManagedAccount; wrap the whole disable+rotate+save sequence in a
per-account lock (e.g., add and use an
accountManager.acquireWorkspaceLock(account.index)/releaseWorkspaceLock) or at
minimum re-check the workspace state after acquiring a short mutex and before
calling disableCurrentWorkspace to ensure it hasn't already been disabled, then
call rotateToNextWorkspace and saveToDiskDebounced under the lock; update the
code path that handles isWorkspaceDisabledError to acquire the lock, perform the
conditional check + disable + rotate + save, release the lock, and only then
continue/disable account/sessionAffinityStore as needed so in-memory mutations
are serialized.

In `@lib/accounts.ts`:
- Around line 876-881: Add a brief doc comment above the hasEnabledWorkspaces
method explaining that when account.workspaces is undefined or an empty array
the method returns true for backwards compatibility by assuming a single
implicit workspace, and that the method otherwise checks for any workspace where
enabled !== false; reference the function name hasEnabledWorkspaces and keep the
comment concise (one or two sentences) to clarify the non-obvious default
behavior.
- Around line 841-854: In disableCurrentWorkspace, replace the direct Date.now()
call with the utility nowMs() so the function uses the same time source as the
rest of the module; update the assignment workspace.disabledAt = nowMs() in the
disableCurrentWorkspace method (ensure the module already imports nowMs from
utils.js or add that import if missing).
- Around line 822-890: Add vitest unit tests in test/accounts.test.ts that cover
the seven new workspace methods: write tests for setWorkspaces and
getCurrentWorkspace including undefined currentWorkspaceIndex, getWorkspaceCount
and getEnabledWorkspaceCount for empty and populated lists,
disableCurrentWorkspace behavior and that disabledAt is set,
rotateToNextWorkspace with mixed enabled/disabled workspaces and when no enabled
workspace exists, and hasEnabledWorkspaces for tracked vs untracked accounts;
include edge-case tests for out-of-bounds currentWorkspaceIndex (negative and >=
length) to ensure methods return null/false as expected; and add a test
simulating concurrent updates to account.currentWorkspaceIndex (e.g., rapid
sequential updates or Promise.all modifications to mimic Windows filesystem
race) to assert deterministic rotation behavior—use the functions setWorkspaces,
rotateToNextWorkspace, disableCurrentWorkspace, getCurrentWorkspace,
hasEnabledWorkspaces, getWorkspaceCount, and getEnabledWorkspaceCount to locate
the code under test.

In `@lib/storage/migrations.ts`:
- Around line 11-17: The Workspace interface is duplicated; export the single
canonical Workspace interface from the persistence-focused module where it
currently exists and remove the duplicate declaration in the other module,
replacing it with an import of that exported Workspace type; update any local
references to use the imported Workspace so only one definition is maintained
and exported for reuse.

In `@test/index-retry.test.ts`:
- Around line 110-117: The test suite is missing regression tests for the
workspace-disabled (403) retry path and the mocks are contradictory
(getCurrentWorkspace() returns null while hasEnabledWorkspaces() returns true);
add tests in test/index-retry.test.ts that (1) simulate a 403 with error code
"workspace-disabled" and assert disableCurrentWorkspace() and
rotateToNextWorkspace() are called, (2) simulate repeated workspace-disabled
responses until no workspaces remain and assert setAccountEnabled(false) is
called, and (3) simulate concurrent requests receiving workspace-disabled to
surface race conditions between rotateToNextWorkspace() and
disableCurrentWorkspace(); also fix the mock state by making
getCurrentWorkspace() return a valid workspace object (with id/name) when
hasEnabledWorkspaces() is true, or set hasEnabledWorkspaces() to false when
getCurrentWorkspace() is null so the mocked state is consistent. Ensure tests
spy/await the async retry logic in index.ts so the calls are observed.
🪄 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: 326ca737-54ca-4e49-901c-2200ed53acaa

📥 Commits

Reviewing files that changed from the base of the PR and between 4a31dca and 8c395ab.

📒 Files selected for processing (4)
  • index.ts
  • lib/accounts.ts
  • lib/storage/migrations.ts
  • test/index-retry.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 (2)
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/index-retry.test.ts
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/storage/migrations.ts
  • lib/accounts.ts
🔇 Additional comments (7)
index.ts (3)

1995-1997: pii emission in log - flagged in previous review.

account.email is still emitted in the logWarn call. this was flagged in a past review comment. the suggested fix was to use masked account reference instead.


1576-1585: lgtm - workspace id resolution fallback.

the code correctly falls back to account.accountId when no workspace is configured. this handles migrated accounts that don't have workspaces populated yet.


625-640: workspace merge preserves enabled flag correctly.

the merge logic at lines 626-629 preserves the existing workspace's enabled status while updating other fields from the new workspace data. this prevents re-enabling a disabled workspace during token refresh.

lib/accounts.ts (2)

82-88: duplicate workspace interface.

this interface is duplicated at lib/storage/migrations.ts:11-17. see comment there for consolidation suggestion.


856-874: lgtm - rotation logic is correct.

rotateToNextWorkspace() correctly searches for the next enabled workspace starting from currentIdx + 1, wrapping around. returns null when all workspaces are disabled.

lib/storage/migrations.ts (2)

36-36: lgtm - switch reason extended.

adding "best" to lastSwitchReason aligns with the cli auth best subcommand in scripts/codex-routing.js.


65-66: workspace fields added to v3 schema but not initialized in migration.

migrateV1ToV3 at lines 80-113 doesn't initialize workspaces or currentWorkspaceIndex. this is acceptable since the workspace methods in lib/accounts.ts:833-890 handle undefined gracefully (e.g., hasEnabledWorkspaces returns true when no workspaces tracked). just noting for clarity.

Comment thread index.ts Outdated
Comment thread index.ts Outdated
Comment thread lib/accounts.ts
Comment thread lib/accounts.ts Outdated
Comment thread lib/accounts.ts
Comment thread lib/storage/migrations.ts Outdated
Comment thread test/index-retry.test.ts Outdated
Comment thread index.ts Outdated
Comment thread index.ts Outdated
Comment thread lib/accounts.ts Outdated
Comment thread lib/accounts.ts
@ndycode ndycode self-assigned this Mar 20, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
index.ts (2)

1577-1585: ⚠️ Potential issue | 🟠 Major

build the entitlement key from the current request workspace.

after a rotate in index.ts:1987-2011, account.accountId still holds the previous workspace when index.ts:1579-1584 runs. that makes the first request on the new workspace record entitlement blocks, capability failures, and quota state under the old workspace id. use accountId for the key, or assign account.accountId = accountId before deriving it.

suggested fix
-											const entitlementAccountKey = resolveEntitlementAccountKey({
-												accountId: account.accountId ?? accountId,
+											account.accountId = accountId;
+											const entitlementAccountKey = resolveEntitlementAccountKey({
+												accountId,
 												email: resolvedEmail,
 												refreshToken: account.refreshToken,
 												index: account.index,
 											});
-											account.accountId = accountId;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@index.ts` around lines 1577 - 1585, The entitlement key is being built using
stale account.accountId (which may still be the previous workspace after
rotation) causing entitlement/quota to be recorded under the wrong workspace;
update the code that computes entitlementAccountKey (the call to
resolveEntitlementAccountKey) to use the current request workspace id
(accountId) instead of account.accountId, or assign account.accountId =
accountId before calling resolveEntitlementAccountKey; ensure you continue to
pass resolvedEmail (from extractAccountEmail(accountAuth.access) ??
account.email), account.refreshToken and account.index unchanged.

559-571: ⚠️ Potential issue | 🟠 Major

persist the selected workspace index with new multi-workspace accounts.

selectBestAccountCandidate() can choose a non-zero slot in index.ts:424-435, but index.ts:559-571 only saves workspaces. lib/accounts.ts:872-878 then falls back to slot 0, so the first request after login or restore can send the wrong workspace id and rewrite account.accountId to the wrong workspace. seed currentWorkspaceIndex from the chosen accountId when you persist the row.

suggested fix
 						accounts.push({
 							accountId,
 							accountIdSource,
 							accountLabel,
 							email: accountEmail,
 							refreshToken: result.refresh,
 							accessToken: result.access,
 							expiresAt: result.expires,
 							addedAt: now,
 							lastUsed: now,
 							workspaces: result.workspaces,
+							currentWorkspaceIndex:
+								result.workspaces && result.workspaces.length > 0
+									? Math.max(
+											0,
+											result.workspaces.findIndex((workspace) => workspace.id === accountId),
+										)
+									: undefined,
 						});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@index.ts` around lines 559 - 571, selectBestAccountCandidate can pick a
non-zero workspace slot but the new account row you push into accounts (in the
accounts.push block in index.ts) only stores workspaces, so lib/accounts.ts
falls back to slot 0; fix by persisting the chosen workspace index into the new
account object: when creating the account entry in the accounts.push (index.ts),
add a currentWorkspaceIndex (or existing field used by lib/accounts.ts) seeded
from the index returned/used by selectBestAccountCandidate so subsequent
requests use the correct workspace and do not rewrite accountId to the wrong
workspace.
lib/accounts.ts (1)

763-772: ⚠️ Potential issue | 🟠 Major

the workspace reset invariant is still bypassed on manual re-enable.

lib/accounts.ts:763-772 only runs when callers use setAccountEnabled(), but index.ts:3261-3266 still flips target.enabled directly in the manage flow. re-enabling an exhausted account from that path leaves every workspace disabled on disk, so the account comes back already exhausted. route that path through this helper, or move the reset into the persistence/update path that all enable toggles share.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/accounts.ts` around lines 763 - 772, The bug: re-enabling an account via
the manage flow flips target.enabled directly and bypasses setAccountEnabled(),
so resetWorkspaces(account) is never called and workspaces remain disabled; fix
by ensuring all enable/disable toggles go through the single helper or a common
persistence/update hook. Concretely, replace the direct assignment of
target.enabled in the manage flow with a call to setAccountEnabled(index,
true/false) (or call resetWorkspaces(target) immediately after any direct set),
or alternatively move the resetWorkspaces logic into the shared
persistence/update routine used by all enable toggles so that any path that
changes account.enabled invokes resetWorkspaces(account). Ensure you reference
setAccountEnabled and resetWorkspaces when updating the manage flow to avoid
skipping the workspace reset.
♻️ Duplicate comments (1)
test/index-retry.test.ts (1)

357-535: 🛠️ Refactor suggestion | 🟠 Major

add the concurrent workspace-disabled regression.

the new cases in test/index-retry.test.ts:357-535 close the single-request paths, but they still only drive one request at a time. the changed branch in index.ts:1987-2028 mutates shared workspace state on a cached ManagedAccount, so we still need a deterministic Promise.all or barrier-based case that sends two 403 workspace-disabled responses through the same account and proves we do not double-disable or disable the account prematurely. As per coding guidelines, test/**: 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 the current code and only fix it if needed.

In `@test/index-retry.test.ts` around lines 357 - 535, Add a deterministic
concurrent regression test to test/index-retry.test.ts that simulates two
simultaneous workspace-disabled 403 responses against the same ManagedAccount to
ensure shared workspace state isn't mutated twice: create an account with a
single workspace, stub globalThis.fetch (or fetchMock) to return two 403
workspace_disabled Responses in sequence, import OpenAIAuthPlugin and loader as
in existing tests, then call Promise.all([sdk.fetch(url, {}), sdk.fetch(url,
{})]) to issue the two requests concurrently and await both; assert response
statuses, that accountManagerState.disableCurrentWorkspaceCalls === 1,
accountManagerState.setAccountEnabledCalls contains a single { index: X,
enabled: false } entry (no double-disable), and that rotateToNextWorkspaceCalls
is correct — follow existing test patterns (e.g., reusing createMockAccount,
OpenAIAuthPlugin, plugin.auth.loader, accountManagerState) and keep the test
deterministic using vitest.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/accounts.test.ts`:
- Around line 225-257: After the first disableCurrentWorkspace call, assert the
parent account remains enabled and only one workspace is still enabled to catch
regressions where a single workspace failure flips account.enabled;
specifically, after expect(manager.disableCurrentWorkspace(account,
"workspace-1")).toBe(true) add assertions checking account.enabled (or
manager.getAccountByIndex(0).enabled) is true and that
account.workspaces?.filter(w => w.enabled).length === 1 (and the enabled flags
equal [false, true]) so the test fails if disableCurrentWorkspace incorrectly
disables the whole account.

In `@test/fetch-helpers.test.ts`:
- Around line 316-374: Add a deterministic vitest case exercising the
split/token filter path for isWorkspaceDisabledError by asserting that wrapped
codes like "error.workspace_disabled" and "workspace_expired:error" (and a
negative wrapped example like "something_else:error") are classified the same as
the canonical codes; place the new it() inside the existing
describe('isWorkspaceDisabledError') block so it calls
isWorkspaceDisabledError(403, 'error.workspace_disabled', '') and
isWorkspaceDisabledError(403, 'workspace_expired:error', '') expecting true, and
a wrapped non-disable token expecting false, ensuring the test covers the
tokenized-code matching branch rather than only exact codes or body-text
regexes.

In `@test/index.test.ts`:
- Around line 230-237: The test mocks are shallow-copying nested workspaces so
mockStorage and the transaction snapshot share the same workspace objects;
update both mock copy sites to deep-clone the workspaces array and each
workspace object (clone workspaces and each item) when creating the mockStorage
snapshot and when creating the transaction snapshot so mutations don't alias;
alternatively change assertions to check saveAccountsMock.mock.calls instead of
in-memory state. Ensure you modify the code paths that reference workspaces,
mockStorage and the transaction snapshot creation so they produce independent
copies.

---

Outside diff comments:
In `@index.ts`:
- Around line 1577-1585: The entitlement key is being built using stale
account.accountId (which may still be the previous workspace after rotation)
causing entitlement/quota to be recorded under the wrong workspace; update the
code that computes entitlementAccountKey (the call to
resolveEntitlementAccountKey) to use the current request workspace id
(accountId) instead of account.accountId, or assign account.accountId =
accountId before calling resolveEntitlementAccountKey; ensure you continue to
pass resolvedEmail (from extractAccountEmail(accountAuth.access) ??
account.email), account.refreshToken and account.index unchanged.
- Around line 559-571: selectBestAccountCandidate can pick a non-zero workspace
slot but the new account row you push into accounts (in the accounts.push block
in index.ts) only stores workspaces, so lib/accounts.ts falls back to slot 0;
fix by persisting the chosen workspace index into the new account object: when
creating the account entry in the accounts.push (index.ts), add a
currentWorkspaceIndex (or existing field used by lib/accounts.ts) seeded from
the index returned/used by selectBestAccountCandidate so subsequent requests use
the correct workspace and do not rewrite accountId to the wrong workspace.

In `@lib/accounts.ts`:
- Around line 763-772: The bug: re-enabling an account via the manage flow flips
target.enabled directly and bypasses setAccountEnabled(), so
resetWorkspaces(account) is never called and workspaces remain disabled; fix by
ensuring all enable/disable toggles go through the single helper or a common
persistence/update hook. Concretely, replace the direct assignment of
target.enabled in the manage flow with a call to setAccountEnabled(index,
true/false) (or call resetWorkspaces(target) immediately after any direct set),
or alternatively move the resetWorkspaces logic into the shared
persistence/update routine used by all enable toggles so that any path that
changes account.enabled invokes resetWorkspaces(account). Ensure you reference
setAccountEnabled and resetWorkspaces when updating the manage flow to avoid
skipping the workspace reset.

---

Duplicate comments:
In `@test/index-retry.test.ts`:
- Around line 357-535: Add a deterministic concurrent regression test to
test/index-retry.test.ts that simulates two simultaneous workspace-disabled 403
responses against the same ManagedAccount to ensure shared workspace state isn't
mutated twice: create an account with a single workspace, stub globalThis.fetch
(or fetchMock) to return two 403 workspace_disabled Responses in sequence,
import OpenAIAuthPlugin and loader as in existing tests, then call
Promise.all([sdk.fetch(url, {}), sdk.fetch(url, {})]) to issue the two requests
concurrently and await both; assert response statuses, that
accountManagerState.disableCurrentWorkspaceCalls === 1,
accountManagerState.setAccountEnabledCalls contains a single { index: X,
enabled: false } entry (no double-disable), and that rotateToNextWorkspaceCalls
is correct — follow existing test patterns (e.g., reusing createMockAccount,
OpenAIAuthPlugin, plugin.auth.loader, accountManagerState) and keep the test
deterministic using vitest.
🪄 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: a287a92e-bc3d-42c7-93f5-ac77a2b03144

📥 Commits

Reviewing files that changed from the base of the PR and between 8c395ab and a858c02.

📒 Files selected for processing (8)
  • index.ts
  • lib/accounts.ts
  • lib/request/fetch-helpers.ts
  • lib/storage/migrations.ts
  • test/accounts.test.ts
  • test/fetch-helpers.test.ts
  • test/index-retry.test.ts
  • test/index.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 (2)
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/storage/migrations.ts
  • lib/request/fetch-helpers.ts
  • lib/accounts.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/accounts.test.ts
  • test/index.test.ts
  • test/fetch-helpers.test.ts
  • test/index-retry.test.ts

Comment thread test/accounts.test.ts
Comment thread test/fetch-helpers.test.ts
Comment thread test/index.test.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants