Skip to content

fix(auth): define reset and delete safety flows#104

Open
ndycode wants to merge 17 commits intodevfrom
fresh/01-reset-safety
Open

fix(auth): define reset and delete safety flows#104
ndycode wants to merge 17 commits intodevfrom
fresh/01-reset-safety

Conversation

@ndycode
Copy link
Owner

@ndycode ndycode commented Mar 16, 2026

Summary

  • Reopens the preserved reset-safety slice from archived PR #75
  • Base PR for the fresh restack onto main

What Changed

  • Hardens destructive account actions so reset and delete flows snapshot, confirm, and clean up state safely instead of risking silent data loss or stale quota artifacts.
  • Keeps the scope focused to destructive-action behavior, login menu confirmation flow, and matching CLI/storage tests.

Validation

  • npm run lint
  • npm run typecheck
  • npm test
  • npm test -- test/documentation.test.ts
  • npm run build

Docs and Governance Checklist

  • README updated (if user-visible behavior changed)
  • docs/getting-started.md updated (if onboarding flow changed)
  • docs/features.md updated (if capability surface changed)
  • relevant docs/reference/* pages updated (if commands/settings/paths changed)
  • docs/upgrade.md updated (if migration behavior changed)
  • SECURITY.md and CONTRIBUTING.md reviewed for alignment

Risk and Rollback

  • Risk level: medium
  • Rollback plan: revert af7f137

Additional Notes

  • Preserved branch validation from the fresh-restack run passed npm run lint, npm run typecheck, npm run build, and 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 extracts all destructive account operations (single-account delete, delete-saved-accounts, reset-local-state) into a new lib/destructive-actions.ts module, replacing the old inline splice+save patterns with snapshot-before/rollback-on-failure semantics. clearQuotaCache is added to lib/quota-cache.ts with EAGAIN/EBUSY/EPERM retry logic consistent with the existing storage helpers, unlinkWithRetry is promoted to a shared helper in lib/storage.ts, and both clearAccounts/clearFlaggedAccounts are hardened to return booleans instead of throwing. the index.ts and codex-manager.ts callers are updated to use Object.assign in-place mutation so in-memory state stays coherent after a deletion, addressing the stale-storage issues from the previous review round. a module-level destructiveActionInFlight flag in codex-manager.ts prevents concurrent destructive actions within a single session; the auth-menu.ts typed-confirm gating (DELETE/RESET) replaces the earlier single-click confirm dialog for destructive danger-zone actions.

key items:

  • deleteAccountAtIndex uses a pre-deletion snapshot (previousStorage) to roll back the accounts file if saveFlaggedAccounts subsequently fails; asSafeAggregateError strips filesystem paths from error messages before they reach logs to avoid leaking home-directory paths on windows
  • clearQuotaCache in lib/quota-cache.ts contains a dead return false after the retry loop — the attempt >= 4 branch inside the loop always returns before the loop exits, making line 309 unreachable
  • when both the flagged-save and the rollback saveAccounts fail, deleteAccountAtIndex throws an AggregateError; both callers in codex-manager.ts and index.ts extract .code from the thrown value, but AggregateError carries no .code — the user always sees the generic retry message in this (windows-likely) double-failure path; the code is available on the sub-errors inside error.errors[]
  • vitest coverage in test/destructive-actions.test.ts and test/quota-cache.test.ts is thorough, including the AggregateError structure, path-redaction, and EAGAIN/EBUSY/EPERM retry exhaustion; the one gap is that no test verifies what the cli prints to the user when the double-failure AggregateError is caught by handleManageAction

Confidence Score: 4/5

  • safe to merge; the two flagged items are a dead-code line and a minor UX gap in an already-rare double-failure scenario
  • the core snapshot/rollback pattern in deleteAccountAtIndex is sound, storage operations are correctly serialized behind withStorageLock, the destructiveActionInFlight mutex prevents concurrent destructive actions, and the typed-confirm gates are appropriately wired. the dead return false in clearQuotaCache is cosmetic. the AggregateError code-extraction issue only surfaces on windows in the specific scenario where both the flagged-accounts save and its rollback both fail, which is uncommon — it produces a generic user message rather than a crash.
  • lib/quota-cache.ts (dead code at line 309) and lib/codex-manager.ts / index.ts catch blocks for deleteAccountAtIndex (AggregateError code extraction)

Important Files Changed

Filename Overview
lib/destructive-actions.ts new module encapsulating all destructive account flows; deleteAccountAtIndex snapshot+rollback pattern is solid, but the double-failure AggregateError path doesn't expose .code to callers
lib/quota-cache.ts adds clearQuotaCache with EAGAIN/EBUSY/EPERM retry logic; path-redaction helper added for windows safety; unreachable return false after the for loop is dead code
lib/storage.ts extracts unlinkWithRetry (with EAGAIN added); clearAccounts now returns boolean and handles legacy paths; clearFlaggedAccounts similarly returns boolean — solid improvement
lib/codex-manager.ts adds module-level destructiveActionInFlight mutex guarding manage/fresh/reset paths; handleManageAction uses deleteAccountAtIndex abstraction but the AggregateError error-code extraction in the catch block doesn't surface a code to the user
index.ts correctly adopts deleteAccountAtIndex and Object.assign in-place refresh for both workingStorage and flaggedStorage; same AggregateError code-extraction gap as codex-manager.ts
lib/cli.ts adds promptTypedConfirm helper with optional shared-rl support; reset and reset-all flows are wired correctly; cancellation copy is consistent
lib/ui/auth-menu.ts adds reset-all menu action; removes the extra confirm prompt for delete-all (handled by typed confirm in caller); ANSI/control-char regex promoted to module-level constants; sanitizeTerminalText applied to search subtitle and status message — good security hardening
test/destructive-actions.test.ts covers rollback success, rollback failure (AggregateError), path-redaction, and clampActiveIndices edge cases; good windows-path redaction assertions
test/quota-cache.test.ts parametrised retry tests cover EAGAIN/EBUSY/EPERM exhaustion; windows-path redaction in clear warnings is tested; good coverage of the new clearQuotaCache
test/codex-manager-cli.test.ts substantially extended with delete/reset/concurrent-action scenarios; in-flight guard, quota-drain ordering, and CLI-sync failure paths are all covered; no test for the AggregateError error-display path through the menu handler

Sequence Diagram

sequenceDiagram
    participant U as User (menu)
    participant CLI as cli.ts promptTypedConfirm
    participant DA as destructive-actions.ts
    participant ST as storage.ts
    participant QC as quota-cache.ts

    U->>CLI: selects "Delete Account" (delete-account confirm)
    CLI-->>U: confirm prompt
    U->>DA: deleteAccountAtIndex({storage, index})
    DA->>ST: loadFlaggedAccounts()
    ST-->>DA: flagged
    DA->>ST: saveAccounts(nextStorage)
    alt saveAccounts succeeds
        ST-->>DA: ok
        DA->>ST: saveFlaggedAccounts(updatedFlagged)
        alt saveFlaggedAccounts succeeds
            ST-->>DA: ok
            DA-->>U: DeleteAccountResult {storage, flagged, removedAccount}
        else saveFlaggedAccounts fails
            DA->>ST: saveAccounts(previousStorage) [rollback]
            alt rollback succeeds
                DA-->>U: throw asSafeAggregateError (code preserved)
            else rollback fails
                DA-->>U: throw AggregateError (no .code on root error)
            end
        end
    end

    U->>CLI: selects "Delete Saved Accounts"
    CLI-->>U: typed confirm (type DELETE)
    U->>DA: deleteSavedAccounts()
    DA->>ST: clearAccounts() [boolean result]
    ST-->>DA: accountsCleared

    U->>CLI: selects "Reset Local State"
    CLI-->>U: typed confirm (type RESET)
    U->>DA: resetLocalState()
    DA->>ST: clearAccounts()
    DA->>ST: clearFlaggedAccounts()
    DA->>QC: clearQuotaCache() [EAGAIN/EBUSY/EPERM retry]
    DA->>DA: clearCodexCliStateCache()
    DA-->>U: DestructiveActionResult {accountsCleared, flaggedCleared, quotaCacheCleared}
Loading

Comments Outside Diff (18)

  1. lib/codex-manager.ts, line 3752-3536 (link)

    handleManageAction delete not guarded by destructiveActionInFlight

    handleManageAction calls deleteAccountAtIndex directly without checking or setting destructiveActionInFlight. in a programmatic or multi-tab session where runAuthLogin could run on two concurrent paths, a single-account delete and a resetLocalState can overlap. the storage-level mutex (withStorageLock) protects individual file writes, but the two-phase commit in deleteAccountAtIndex (write-accounts → write-flagged or rollback) can interleave with clearAccounts in resetLocalState, leaving accounts or flagged entries in a partially-reset state.

    the same guard used for fresh/reset should wrap the individual-delete path, or at minimum the function should bail out early when a destructive action is already in flight:

    if (typeof menuResult.deleteAccountIndex === "number") {
      const idx = menuResult.deleteAccountIndex;
      if (idx >= 0 && idx < storage.accounts.length) {
        if (destructiveActionInFlight) {
          console.log("Another destructive action is already running. Wait for it to finish.");
          return;
        }
        destructiveActionInFlight = true;
        try {
          const deleted = await deleteAccountAtIndex({ storage, index: idx });
          ...
        } finally {
          destructiveActionInFlight = false;
        }
      }
      return;
    }
    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: lib/codex-manager.ts
    Line: 3752-3536
    
    Comment:
    **`handleManageAction` delete not guarded by `destructiveActionInFlight`**
    
    `handleManageAction` calls `deleteAccountAtIndex` directly without checking or setting `destructiveActionInFlight`. in a programmatic or multi-tab session where `runAuthLogin` could run on two concurrent paths, a single-account delete and a `resetLocalState` can overlap. the storage-level mutex (`withStorageLock`) protects individual file writes, but the two-phase commit in `deleteAccountAtIndex` (write-accounts → write-flagged or rollback) can interleave with `clearAccounts` in `resetLocalState`, leaving accounts or flagged entries in a partially-reset state.
    
    the same guard used for `fresh`/`reset` should wrap the individual-delete path, or at minimum the function should bail out early when a destructive action is already in flight:
    
    ```typescript
    if (typeof menuResult.deleteAccountIndex === "number") {
      const idx = menuResult.deleteAccountIndex;
      if (idx >= 0 && idx < storage.accounts.length) {
        if (destructiveActionInFlight) {
          console.log("Another destructive action is already running. Wait for it to finish.");
          return;
        }
        destructiveActionInFlight = true;
        try {
          const deleted = await deleteAccountAtIndex({ storage, index: idx });
          ...
        } finally {
          destructiveActionInFlight = false;
        }
      }
      return;
    }
    ```
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Codex

  2. lib/destructive-actions.ts, line 780-782 (link)

    throw originalError bypasses path sanitization on successful rollback

    when saveFlaggedAccounts fails but the rollback write succeeds, throw originalError is reached. originalError was constructed by asError(), not asSafeAggregateError(), so its .message still contains the raw fs error — which on Windows can include the full %HOMEPATH% path (e.g. EPERM: …, rename 'C:\Users\alice\.codex\…'). only the AggregateError branch (rollback also fails) sanitizes both messages. this inconsistency means the successful-rollback path still leaks the username.

  3. lib/storage.ts, line 2288-2325 (link)

    clearFlaggedAccounts returns false even when data was successfully deleted

    when the data files (path + backups) are deleted without error but the marker cleanup at the end fails, hadError is set to true and the function returns false. the caller resetLocalState then sets flaggedCleared: false and surfaces the "completed with warnings" message to the user — even though the flagged accounts are already gone from disk.

    there's a second consequence: the stale marker file remains. while the next call to clearFlaggedAccounts overwrites it immediately (the fs.writeFile happens before any deletion), recovery code that inspects the marker without first calling clear could mis-classify state.

    the marker cleanup failure should be tracked separately from data deletion failure so callers can distinguish "data not deleted" from "data deleted but cleanup artifact remained":

    let dataError = false;
    let markerError = false;
    for (const candidate of [path, ...backupPaths]) {
      try {
        await unlinkWithRetry(candidate);
      } catch (error) {
        const code = (error as NodeJS.ErrnoException).code;
        if (code !== "ENOENT") {
          dataError = true;
          log.error("Failed to clear flagged account storage", { path: candidate, error: String(error) });
        }
      }
    }
    if (!dataError) {
      try {
        await unlinkWithRetry(markerPath);
      } catch (error) {
        const code = (error as NodeJS.ErrnoException).code;
        if (code !== "ENOENT") {
          markerError = true;
          log.error("Failed to clear flagged reset marker", { path, markerPath, error: String(error) });
        }
      }
    }
    return !dataError; // marker cleanup failure is logged but does not fail the clear

    the windows EPERM/EBUSY window on the marker file is real; separating the two failure categories prevents a benign transient lock on the marker from reporting a false failure to the user.

  4. lib/codex-manager.ts, line 3817-3837 (link)

    deleteAccountAtIndex bypasses the destructiveActionInFlight guard

    deleteSavedAccounts and resetLocalState both check and set the module-level destructiveActionInFlight flag, but handleManageAction (which calls deleteAccountAtIndex) does not. if two runCodexMultiAuthCli invocations run concurrently — common on windows desktops where a user may open two terminals — both could enter the manage-delete path simultaneously.

    deleteAccountAtIndex calls saveAccounts (which holds the storage lock), so the final write is serialized, but the read-modify-write cycle is not atomic across invocations: both callers snapshot the same original storage, both splice the same index, and the second write silently wins with a stale view.

    wrap the call with the same guard used for the other destructive actions:

    if (typeof menuResult.deleteAccountIndex === "number") {
      const idx = menuResult.deleteAccountIndex;
      if (idx >= 0 && idx < storage.accounts.length) {
        if (destructiveActionInFlight) {
          console.log("Another destructive action is already running. Wait for it to finish.");
          return;
        }
        destructiveActionInFlight = true;
        try {
          const deleted = await deleteAccountAtIndex({ storage, index: idx });
          if (deleted) {
            Object.assign(storage, deleted.storage);
            // ...
          }
        } finally {
          destructiveActionInFlight = false;
        }
      }
      return;
    }

    there's no vitest coverage for concurrent manage-delete to catch this race; a test/codex-manager-cli.test.ts case similar to "skips a second destructive action while reset is already running" would help verify the guard.

  5. lib/codex-manager.ts, line 3988-3992 (link)

    invalidateAccountManagerCache() missing after deleteSavedAccounts

    after runActionPanel completes and the function hits continue, the account manager cache still holds stale data from before the delete. index.ts calls invalidateAccountManagerCache() immediately after the same destructive action (line 3152 in index.ts), but codex-manager.ts does not. the next menu loop iteration may re-render with old account entries still in the in-memory cache.

    same pattern repeats for the reset mode block (line ~4021). both need the invalidation before continue.

  6. lib/codex-manager.ts, line 4019-4023 (link)

    invalidateAccountManagerCache() missing after resetLocalState

    same issue as the fresh+deleteAll block above: after resetLocalState() the account manager cache is not invalidated before continue. index.ts calls invalidateAccountManagerCache() at line 3167, but this path does not. the next menu render can show ghost accounts from the pre-reset cache state.

  7. lib/quota-cache.ts, line 975-980 (link)

    error.message may leak Windows path in logs

    clearQuotaCache logs error.message directly into logWarn. on windows, fs.unlink errors look like "EPERM: operation not permitted, unlink 'C:\Users\johndoe\.codex\multi-auth\quota-cache.json'" — that embeds the windows username. the asSafeAggregateError helper in destructive-actions.ts exists exactly for this reason (it strips messages containing path separators), but this new function doesn't use it. logWarn output can end up in telemetry sinks per the project's risk policy.

    same issue exists in the catch block of saveQuotaCache and loadQuotaCache, but those are pre-existing. clearQuotaCache is new code added in this PR and should follow the redaction strategy from day one.

    or, pull in asSafeAggregateError from destructive-actions.ts (or extract the redaction to a shared util) and use it here.

    Rule Used: What: Every code change must explain how it defend... (source)

  8. index.ts, line 3154-3168 (link)

    index.ts delete flow has no destructiveActionInFlight guard

    lib/codex-manager.ts carefully wraps every destructive action with a module-level destructiveActionInFlight flag to prevent concurrent double-deletes, but this index.ts code path has no equivalent guard.

    two simultaneous terminal sessions both showing the auth menu (both running the index.ts loop) can each independently call deleteAccountAtIndex with the same original workingStorage snapshot. both calls compute nextStorage by splicing the same index out of the same accounts array, both call saveAccounts(nextStorage), and the second write silently overwrites the first — restoring the account that the first session just deleted. the underlying withStorageLock mutex serialises the writes, but it doesn't protect against the read-compute-write race at the application layer.

    this is the same concurrency contract that codex-manager.ts explicitly defends against with the in-flight flag and its accompanying test ("skips a second manage delete while another destructive action is already running"). the index.ts path should either adopt the same flag (declared at module scope in index.ts) or delegate fully to codex-manager.ts so the guard is not duplicated.

  9. index.ts, line 3163-3168 (link)

    Email dropped from delete confirmation log

    deleteAccountAtIndex returns deleted.removedAccount (type AccountMetadataV3) which includes the original email field, but the caller always constructs label from the index number instead. the old code logged target.email ?? \Account N`` — this PR regresses to always showing "Account N", so users lose the confirmation that they deleted the right account.

    same pattern also appears in lib/codex-manager.ts around line 3533 (const label = \Account ${idx + 1}``).

  10. lib/codex-manager.ts, line 3533 (link)

    Email dropped from delete log (manager path)

    same regression as index.ts: deleted.removedAccount.email is available but ignored — users see "Account 2" instead of "user@example.com". DeleteAccountResult.removedAccount is typed to carry the full AccountMetadataV3 including email, use it here.

  11. lib/destructive-actions.ts, line 811-817 (link)

    deleteSavedAccounts propagates uncaught clearAccounts throws

    clearAccounts() can throw before it reaches the best-effort artifact loop — specifically at the fs.mkdir and fs.writeFile calls for the reset-marker, both of which are bare awaits with no enclosing try-catch. on windows, AV or an OS file-lock on %HOMEPATH% can produce EPERM here, causing deleteSavedAccounts to reject.

    the caller in index.ts (const result = await deleteSavedAccounts()) has no surrounding try-catch, so that exception escapes the while menu loop entirely and crashes the process.

    resetLocalState handles this correctly with .catch(() => false). apply the same guard here:

  12. test/destructive-actions.test.ts, line 3905-3947 (link)

    AggregateError rollback path has no path-redaction coverage

    the existing test covers the single-failure path (flagged-save fails, rollback succeeds → rethrows original error). but there is zero test for the double-failure path: saveFlaggedAccounts throws with a windows-style path in the message, AND saveAccounts(previousStorage) also throws with a path-containing message. in that case the code reaches:

    throw new AggregateError(
      [
        asSafeAggregateError(originalError, "..."),
        asSafeAggregateError(rollbackError, "..."),
      ],
      "Deleting the account partially failed and rollback also failed.",
    );

    both inner errors are wrapped by asSafeAggregateError, which creates sanitised Error objects, but there is no test asserting that (a) the AggregateError is thrown rather than a plain error, and (b) the inner errors do not contain the windows username path. given rule 637a42e6 requires explicit concurrency reasoning with a matching regression test for every windows filesystem path, this gap should be closed.

    a minimal addition to test/destructive-actions.test.ts:

    it("redacts paths from AggregateError when both flagged-save and rollback fail", async () => {
      const pathyFlaggedError = Object.assign(
        new Error("EPERM: operation not permitted, unlink 'C:\\Users\\alice\\.codex\\flagged.json'"),
        { code: "EPERM" },
      );
      const pathyRollbackError = Object.assign(
        new Error("EPERM: operation not permitted, rename 'C:\\Users\\alice\\.codex\\accounts.tmp'"),
        { code: "EPERM" },
      );
      saveFlaggedAccountsMock.mockRejectedValueOnce(pathyFlaggedError);
      saveAccountsMock
        .mockResolvedValueOnce(undefined) // initial save succeeds
        .mockRejectedValueOnce(pathyRollbackError); // rollback fails
    
      loadFlaggedAccountsMock.mockResolvedValue({
        version: 1,
        accounts: [{ refreshToken: "rf", addedAt: 1, lastUsed: 1, flaggedAt: 1 }],
      });
    
      const { deleteAccountAtIndex } = await import("../lib/destructive-actions.js");
      const storage = {
        version: 3,
        activeIndex: 0,
        activeIndexByFamily: { codex: 0 },
        accounts: [
          { refreshToken: "keep", addedAt: 1, lastUsed: 1 },
          { refreshToken: "rf", addedAt: 2, lastUsed: 2 },
        ],
      };
    
      const err = await deleteAccountAtIndex({ storage, index: 1 }).catch((e) => e);
      expect(err).toBeInstanceOf(AggregateError);
      const msgs = (err as AggregateError).errors.map((e: Error) => e.message).join(" ");
      expect(msgs).not.toContain("alice");
      expect(msgs).not.toContain("C:\\");
    });

    Rule Used: What: Every code change must explain how it defend... (source)

  13. lib/destructive-actions.ts, line 775-803 (link)

    Uncaught throw from deleteAccountAtIndex crashes both callers

    when saveFlaggedAccounts throws (e.g. windows EPERM while antivirus holds the file) and rollback succeeds, this function re-throws a safe error at line 799. both callers — index.ts and codex-manager.ts — have no try-catch around deleteAccountAtIndex, so the exception propagates unhandled and terminates the interactive session entirely.

    lib/codex-manager.ts has a try-finally that resets destructiveActionInFlight, but the error still bubbles to runAuthLogin without being caught. index.ts has no try protection at all around the call.

    the rollback guarantees data integrity, but the UX result is a crash. add a try-catch in each caller to display a friendly error message and let the menu loop continue:

    // in codex-manager.ts handleManageAction
    try {
        const deleted = await deleteAccountAtIndex({ storage, index: idx });
        if (deleted) { /* … */ }
    } catch (err) {
        console.log(`Failed to delete account: ${err instanceof Error ? err.message : String(err)}`);
    } finally {
        destructiveActionInFlight = false;
    }

    same pattern needed in index.ts around the deleteAccountAtIndex call (lines ~3154-3172).

  14. lib/cli.ts, line 336-393 (link)

    Nested readline interfaces race on the same stdin

    promptLoginModeFallback holds an outer rl open for the main loop (created at the top of the function) and then calls promptDeleteAllTypedConfirm() / promptResetTypedConfirm(), each of which creates a second createInterface({ input, output }) on the same process.stdin. While the outer rl is only passive (not mid-question()) during the inner call, Node.js readline attaches its own keypress/data event listeners as soon as the interface is created. Two active interfaces on the same stream compete for input events; on Windows in particular, the second interface can miss the newline terminator that the first interface re-emits when it resumes, causing the next outer rl.question() call to return immediately with an empty string and loop unexpectedly.

    the TTY path (showAuthMenupromptLoginMode) avoids this because the interactive selector fully completes before a readline interface is opened. the fallback path does not — consider closing and reopening rl around each destructive-action confirmation, or hoisting all readline interactions into the outer loop so only one interface is ever live at a time.

  15. lib/destructive-actions.ts, line 772-782 (link)

    Path-detection regex fires on non-path slashes

    asSafeAggregateError redacts the whole error message whenever it contains \ or /. that heuristic also triggers on completely legitimate messages like "read/write error", "n/a", "operation x/y failed", etc. — stripping the only useful diagnostic context.

    node.js filesystem errors reliably start with ERRNO: description, syscall '/full/path' so a tighter check like /(^|[ "'])(?:[a-zA-Z]:)?[/\\]/.test(msg) would catch real path-bearing messages without destroying generic ones. the existing rollback tests don't exercise this case, so a false-positive here would silently swallow the true error code.

  16. lib/codex-manager.ts, line 3562-3590 (link)

    deleted.flagged is silently dropped — flaggedStorage stays stale in codex-manager.ts path

    deleteAccountAtIndex internally calls loadFlaggedAccounts(), filters out the matching entry, and persists the result. it returns the updated flagged snapshot as deleted.flagged. here, Object.assign(storage, deleted.storage) correctly updates accounts in memory, but deleted.flagged is never applied to any local flaggedStorage reference in the caller.

    the index.ts flow was fixed for this exact issue (previous thread on line 3168 there), but handleManageAction in codex-manager.ts doesn't receive or propagate flaggedStorage at all. if runAuthLogin passes a local flaggedStorage count to showAuthMenu for the "verify-flagged" badge, deleting an account with a matching flagged entry will leave the badge count inflated until a full re-entry into the menu re-reads from disk.

    handleManageAction should either accept a flaggedStorage parameter and Object.assign it like index.ts does, or return the deleted.flagged value so the caller can update its local copy.

    (or thread flaggedStorage as a parameter to handleManageAction and assign unconditionally)

  17. lib/destructive-actions.ts, line 800-806 (link)

    Accounts written to disk before flagged save — rollback window still exists

    saveAccounts(nextStorage) commits to disk unconditionally (line 805), then saveFlaggedAccounts is attempted. on Windows, the OS file lock from the first saveAccounts rename may still be held when the rollback saveAccounts(previousStorage) is issued — EPERM/EBUSY during rollback is a real failure mode on user desktops. saveAccounts has its own retry loop, but a retry-exhausted rollback surfaces the AggregateError with both the original and rollback errors.

    this was already noted in previous review thread on this file. the existing tests cover the AggregateError path but there is no vitest regression that exercises an EBUSY rollback write that eventually succeeds after the lock clears — i.e. a scenario where saveFlaggedAccounts fails with EPERM but saveAccounts(previousStorage) succeeds only on the 3rd retry. consider adding a test that stubs saveAccounts to throw EBUSY on the rollback attempt once, then resolve — confirming the final on-disk state matches previousStorage and no AggregateError is thrown.

  18. lib/codex-manager.ts, line 3848-3854 (link)

    AggregateError swallows the error code; double-failure shows generic message

    when both saveFlaggedAccounts and the rollback saveAccounts fail inside deleteAccountAtIndex, the function throws an AggregateError. AggregateError doesn't carry a .code property, so (error as NodeJS.ErrnoException)?.code returns undefined and the user always sees the generic "Failed to delete account. Please retry." with no code — even on windows where this double-failure scenario is most likely (EPERM/EBUSY on the rename + another EPERM on the rollback unlink). the individual sub-errors inside error.errors[] do have .code set via asSafeAggregateError, but they're never inspected here.

    the same pattern repeats in index.ts at lines 3127-3132.

    consider probing the sub-errors for a code when the top-level .code is absent:

    const aggregateErrors = error instanceof AggregateError ? error.errors : [];
    const code =
      (error as NodeJS.ErrnoException | undefined)?.code ??
      (aggregateErrors[0] as NodeJS.ErrnoException | undefined)?.code;

Fix All in Codex

Prompt To Fix All With AI
This is a comment left during a code review.
Path: lib/quota-cache.ts
Line: 307-309

Comment:
**Unreachable `return false` after the loop**

the `for` loop's internal early-exit path covers every case: on the final attempt (`attempt === 4`) the condition `!isRetryableFsError(error) || attempt >= 4` always evaluates to `true`, so `return false` is hit inside the loop before the loop exits. the `return false` at line 309 is dead code that can never be reached. this could mislead a future reader into thinking there's a 6th attempt being made.

```suggestion
	}
}
```

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/codex-manager.ts
Line: 3848-3854

Comment:
**`AggregateError` swallows the error code; double-failure shows generic message**

when both `saveFlaggedAccounts` and the rollback `saveAccounts` fail inside `deleteAccountAtIndex`, the function throws an `AggregateError`. `AggregateError` doesn't carry a `.code` property, so `(error as NodeJS.ErrnoException)?.code` returns `undefined` and the user always sees the generic "Failed to delete account. Please retry." with no code — even on windows where this double-failure scenario is most likely (EPERM/EBUSY on the rename + another EPERM on the rollback unlink). the individual sub-errors inside `error.errors[]` do have `.code` set via `asSafeAggregateError`, but they're never inspected here.

the same pattern repeats in `index.ts` at lines 3127-3132.

consider probing the sub-errors for a code when the top-level `.code` is absent:
```ts
const aggregateErrors = error instanceof AggregateError ? error.errors : [];
const code =
  (error as NodeJS.ErrnoException | undefined)?.code ??
  (aggregateErrors[0] as NodeJS.ErrnoException | undefined)?.code;
```

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

Last reviewed commit: "fix(auth): harden re..."

Copilot AI review requested due to automatic review settings March 16, 2026 08:51
@chatgpt-codex-connector
Copy link

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
Contributor

coderabbitai bot commented Mar 16, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

introduces a destructive-actions framework and a guarded reset flow; replaces in-place storage mutations with coordinated helpers; adds retryable unlink/rename and quota-cache APIs; surfaces typed confirmations in cli/ui; and expands tests for destructive actions, retry semantics, sanitization, and rollback behaviors.

Changes

Cohort / File(s) Summary
documentation
README.md, docs/troubleshooting.md
quick start and install text updated to reference codex auth list. troubleshooting renamed/expanded to “reset options” with an exact-effects table and stricter file-cleanup steps. review wording consistency.
destructive actions module
lib/destructive-actions.ts
new module implementing DESTRUCTIVE_ACTION_COPY, deleteAccountAtIndex, deleteSavedAccounts, resetLocalState, index rebasing, rollback and aggregated error handling. focus review on rebase/rollback and error redaction (lib/destructive-actions.ts:line *).
storage changes
lib/storage.ts
adds unlinkWithRetry and renameFileWithRetry. clearAccounts() and clearFlaggedAccounts() now return boolean and perform best-effort cleanup across legacy, WAL, and .bak paths with retries and ENOENT short-circuit. verify retry/backoff and aggregate outcome (lib/storage.ts:line *).
quota cache
lib/quota-cache.ts
introduces dynamic quota-cache path helpers, getQuotaCachePath() and clearQuotaCache() with retry/backoff and sanitized error formatting. review path resolution and windows path redaction (lib/quota-cache.ts:line *).
cli and manager integration
lib/cli.ts, lib/codex-manager.ts, index.ts
adds reset login mode and typed confirmations, replaces splices/clears with destructive-action helpers, and uses a single destructiveActionInFlight guard. audit concurrency, single-guard correctness, and standardized messaging (lib/codex-manager.ts:line *, lib/cli.ts:line *, index.ts:line *).
ui and copy
lib/ui/auth-menu.ts, lib/ui/copy.ts
adds reset-all/resetLocalState UI entries, ANSI/control-char sanitization, adjusted danger-zone labels and hotkeys. check rendering sanitization and focus/hotkey behavior (lib/ui/auth-menu.ts:line *).
tests (new & updated)
test/destructive-actions.test.ts, test/quota-cache.test.ts, test/storage.test.ts, test/storage-flagged.test.ts, test/codex-manager-cli.test.ts, test/cli-auth-menu.test.ts, test/cli.test.ts, test/...
adds comprehensive destructive-action unit tests, retry/backoff tests for storage/quota, sanitization tests, and updated cli expectations. verify mocks wiring for destructive-actions (test/codex-manager-cli.test.ts:line *) and windows-file-lock scenarios in storage/quota tests (test/storage-flagged.test.ts:line *, test/quota-cache.test.ts:line *).
other
README.md, test/release-main-prs-regression.test.ts
minor message wording changes (e.g., “delete saved accounts”), tests updated to expect boolean-return semantics from clear operations. see test/release-main-prs-regression.test.ts:line *.

Sequence Diagram

sequenceDiagram
    participant user as user/cli
    participant menu as auth menu
    participant mgr as codex manager
    participant actions as destructive actions
    participant storage as storage handlers
    participant fs as filesystem

    user->>menu: select delete or reset
    menu->>menu: require typed confirmation
    menu->>mgr: invoke login flow (mode=delete|reset)
    mgr->>mgr: check destructiveActionInFlight
    mgr->>actions: call deleteAccountAtIndex / deleteSavedAccounts / resetLocalState
    actions->>storage: perform saveAccounts / saveFlagged / clearQuotaCache / unlink targets
    storage->>fs: unlink/rename with retry/backoff
    fs-->>storage: operation result or error
    storage-->>actions: aggregated result (success/partial/failure)
    actions-->>mgr: result payload (accountsCleared, flaggedCleared, quotaCacheCleared, removedFlaggedCount)
    mgr-->>user: log completion or sanitized error
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

additional reviewer flags:

  • windows file-lock regressions: add explicit tests that simulate persistent EPERM/EBUSY/EAGAIN on windows-style paths. see lib/storage.ts:line *, lib/quota-cache.ts:line *, and ensure test/storage-flagged.test.ts:line * and test/quota-cache.test.ts:line * cover these cases.
  • concurrency risk: destructiveActionInFlight is a single boolean guard. audit races and add a stress test that fires concurrent destructive requests to lib/codex-manager.ts:line * and index.ts:line *. see test/codex-manager-cli.test.ts:line * for existing mocks to extend.
  • error redaction: verify asSafeAggregateError redacts windows and mixed-paths in messages. inspect lib/destructive-actions.ts:line * and add targeted tests for path redaction.
  • rollback semantics: review rollback and AggregateError propagation paths in lib/destructive-actions.ts:line * and corresponding tests in test/destructive-actions.test.ts:line *. ensure filesystem paths are never leaked.
  • missing test coverage: add an explicit regression test that simulates a long-running quota refresh while a destructive action is requested to validate ordering and prevention of overlapping actions. reference test/codex-manager-cli.test.ts:line *.
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 21.05% 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 The title 'fix(auth): define reset and delete safety flows' clearly describes the main change: hardening destructive account actions with safety flows. It follows the required format (type(scope): summary) with 47 characters and all lowercase.
Description check ✅ Passed PR description is comprehensive and addresses the template. Includes summary, what changed, validation checklist (all checked), docs/governance checklist (appropriately marked as N/A for scope), risk level, and rollback plan.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fresh/01-reset-safety
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch fresh/01-reset-safety
📝 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.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR hardens destructive auth/storage actions by introducing explicit “Delete Saved Accounts” vs “Reset Local State” flows, adding retryable cleanup for transient filesystem errors, and aligning the TUI + fallback CLI prompts and docs with the new behavior.

Changes:

  • Add lib/destructive-actions.ts to centralize delete/reset behaviors and ensure safe rollback when deleting a single account.
  • Make account/flagged/quota-cache cleanup more resilient (retry transient unlink/rename failures; return success/failure booleans instead of throwing in some cases).
  • Update auth menu/CLI prompts, and expand tests/docs to cover the new destructive flows.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
test/storage.test.ts Adds coverage for retryable deletes, legacy artifact cleanup, and quota-cache clearing.
test/storage-flagged.test.ts Updates expectations for flagged clear to return boolean outcomes and exercise retry behavior.
test/release-main-prs-regression.test.ts Aligns regression expectation with new “return false” behavior.
test/quota-cache.test.ts Adds tests for dynamic quota-cache path resolution and clear retries.
test/destructive-actions.test.ts New unit tests for delete/reset flows and delete rollback semantics.
test/codex-manager-cli.test.ts Updates CLI tests to use destructive-actions entrypoints and adds concurrency/waiting scenarios.
test/cli.test.ts Extends CLI prompt tests for typed confirmations and reset flow.
test/cli-auth-menu.test.ts Adds reset-all flow tests and updates cancellation copy.
test/auth-menu-hotkeys.test.ts Adds tests for reset/delete actions, plus sanitization assertions.
lib/ui/copy.ts Updates danger-zone copy and adds reset option label.
lib/ui/auth-menu.ts Adds reset-all action, removes extra confirm for delete-all/reset-all, and sanitizes terminal text.
lib/storage.ts Introduces unlink retry helper; changes clear APIs to return boolean and expands artifact deletion set.
lib/quota-cache.ts Makes quota cache path dynamic per call and adds clearQuotaCache() with retries + boolean result.
lib/destructive-actions.ts New module defining delete/reset flows and safe delete-with-rollback for single accounts.
lib/codex-manager.ts Switches to destructive-actions and adds in-flight destructive-action gating.
lib/cli.ts Adds typed confirm prompts wired to destructive-action copy and supports reset mode.
index.ts Switches to destructive-actions for delete/reset and single-account delete behavior.
docs/troubleshooting.md Documents new reset options and manual equivalents.
README.md Updates install + usage examples (adds codex auth list and Codex install).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 3755 to 3766
const deleted = await deleteAccountAtIndex({
storage,
index: idx,
});
if (deleted) {
const label = `Account ${idx + 1}`;
const flaggedNote =
deleted.removedFlaggedCount > 0
? ` Removed ${deleted.removedFlaggedCount} matching problem account${deleted.removedFlaggedCount === 1 ? "" : "s"}.`
: "";
console.log(`Deleted ${label}.${flaggedNote}`);
}
index.ts Outdated
Comment on lines 3108 to 3120
const deleted = await deleteAccountAtIndex({
storage: workingStorage,
index: menuResult.deleteAccountIndex,
});
if (deleted) {
invalidateAccountManagerCache();
console.log(`\nDeleted ${target.email ?? `Account ${menuResult.deleteAccountIndex + 1}`}.\n`);
const label = `Account ${menuResult.deleteAccountIndex + 1}`;
const flaggedNote =
deleted.removedFlaggedCount > 0
? ` Removed ${deleted.removedFlaggedCount} matching problem account${deleted.removedFlaggedCount === 1 ? "" : "s"}.`
: "";
console.log(`\nDeleted ${label}.${flaggedNote}\n`);
}
lib/storage.ts Outdated
Comment on lines 2001 to 2002
* Deletes the account storage file from disk.
* Silently ignores if file doesn't exist.
* Silently ignores if file doesn't exist.
*/
export async function clearAccounts(): Promise<void> {
export async function clearAccounts(): Promise<boolean> {
}

export async function clearFlaggedAccounts(): Promise<void> {
export async function clearFlaggedAccounts(): Promise<boolean> {
Comment on lines +182 to +200
async function unlinkWithRetry(path: string): Promise<void> {
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
await fs.unlink(path);
return;
} catch (error) {
const unlinkError = error as NodeJS.ErrnoException;
const code = unlinkError.code;
if (code === "ENOENT") {
return;
}
if ((code === "EPERM" || code === "EBUSY" || code === "EAGAIN") && attempt < 4) {
await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt));
continue;
}
throw unlinkError;
}
}
}
Copy link
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: 13

Caution

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

⚠️ Outside diff range comments (4)
test/cli-auth-menu.test.ts (1)

282-295: ⚠️ Potential issue | 🟡 Minor

assert readline cleanup on the cancel paths.

test/cli-auth-menu.test.ts:282-323,325-337 covers the negative typed-confirm flows, but it never checks mockRl.close. test/cli-auth-menu.test.ts:279 already locks the success path down; the cancel paths should do the same, or a leaked readline handle can slip through and hang interactive exits.

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. reject changes that mock real secrets or skip assertions.

Also applies to: 308-323, 325-337

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

In `@test/cli-auth-menu.test.ts` around lines 282 - 295, The cancel-path tests for
promptLoginMode (e.g., the "cancels delete-all when typed confirmation is not
DELETE" case that stubs showAuthMenu and uses mockRl.question) do not assert
that the readline handle was closed; add an assertion that mockRl.close was
called (e.g., expect(mockRl.close).toHaveBeenCalled()) after awaiting
promptLoginMode in each negative/ cancel flow test, and ensure any console spies
or mocks are restored afterwards so the readline mock cannot leak across tests.
test/storage-flagged.test.ts (1)

15-16: 🧹 Nitpick | 🔵 Trivial

RETRYABLE_REMOVE_CODES includes ENOTEMPTY but no test coverage for it.

the set at line 15 includes ENOTEMPTY but the parameterized tests only cover EPERM and EBUSY. consider adding ENOTEMPTY to the test matrix for completeness, though it's less common in this context.

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

In `@test/storage-flagged.test.ts` around lines 15 - 16, The test matrix is
missing coverage for the ENOTEMPTY case even though RETRYABLE_REMOVE_CODES
includes it; update the parameterized tests that iterate over retryable error
codes (the test using RETRYABLE_REMOVE_CODES or the array of codes in the
parameterized test) to include "ENOTEMPTY" alongside "EPERM" and "EBUSY" so the
behavior for ENOTEMPTY is asserted the same as the other retryable codes; locate
the parameterized test in storage-flagged.test.ts that references EPERM/EBUSY
and add ENOTEMPTY to that list so the test exercises the same retry logic as the
RETRYABLE_REMOVE_CODES constant.
test/codex-manager-cli.test.ts (2)

456-567: ⚠️ Potential issue | 🟠 Major

force non-interactive tty in the default setup.

test/codex-manager-cli.test.ts:564 restores the host descriptors but never forces them off. on a real tty, the new destructive-action cases can enter runActionPanel() / waitForMenuReturn() via lib/codex-manager.ts:3966 and change logging behavior or hang on input. default this suite to setInteractiveTTY(false), and let setupInteractiveSettingsLogin() remain the only opt-in path to true.

proposed fix
 		selectMock.mockResolvedValue(undefined);
 		restoreTTYDescriptors();
+		setInteractiveTTY(false);
 		setStoragePathMock.mockReset();
 		getStoragePathMock.mockReturnValue("/mock/openai-codex-accounts.json");
 	});

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. reject changes that mock real secrets or skip assertions.

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

In `@test/codex-manager-cli.test.ts` around lines 456 - 567, The test suite leaves
the process TTY in an interactive state causing non-deterministic hangs via
runActionPanel()/waitForMenuReturn; add a deterministic default by calling
setInteractiveTTY(false) in the beforeEach setup (alongside the existing
restoreTTYDescriptors() call) so tests default to non-interactive; keep
setupInteractiveSettingsLogin() as the only place that opts into true.
Reference: the test file's beforeEach block and the helpers setInteractiveTTY
and setupInteractiveSettingsLogin (and the runtime paths runActionPanel /
waitForMenuReturn) to locate where to insert the call.

3391-3449: ⚠️ Potential issue | 🟠 Major

make the delete test assert the rebased pool, not just the helper call.

test/codex-manager-cli.test.ts:3393 pins loadAccountsMock to the pre-delete pool for every read, so this case still passes even if lib/codex-manager.ts:3825 stops refreshing local state or the helper never persists the deletion. back the mock with mutable state and assert the next menu render sees the one-account pool.

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. reject changes that mock real secrets or skip assertions.

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

In `@test/codex-manager-cli.test.ts` around lines 3391 - 3449, The test currently
pins loadAccountsMock to a static pre-delete pool so it only verifies
deleteAccountAtIndexMock was called; change the mock to use mutable state (e.g.,
a local in-test variable representing current storage) and have loadAccountsMock
return that state on each call, update that state when deleteAccountAtIndexMock
is invoked (or make deleteAccountAtIndexMock resolve to the new storage), then
after runCodexMultiAuthCli(["auth","login"]) assert the next menu/render
reflects the rebased one-account pool (verify loadAccountsMock returned the
post-deletion storage and the UI/menu contains only the remaining email) instead
of only checking deleteAccountAtIndexMock call; keep using vitest-style mocks
and assertions (loadAccountsMock, deleteAccountAtIndexMock, promptLoginModeMock,
runCodexMultiAuthCli).
🤖 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 3108-3121: The advanced-tool deletion path still mutates storage
inline and bypasses the safety/rollback/in-flight/flagged-sync guarantees
implemented in lib/destructive-actions.ts; change that path to call the same
deleteAccountAtIndex helper used in the manage path (preserving its call
signature: { storage, index }) and then apply the returned deleted.storage and
deleted.flagged via Object.assign, call invalidateAccountManagerCache(), and
surface deleted.removedFlaggedCount in the same console message flow; remove the
inline mutation logic so all deletions go through deleteAccountAtIndex to ensure
consistency with test/destructive-actions.test.ts.
- Around line 3164-3178: The reset/fresh branches call resetLocalState and set
startFresh without coordinating with the existing destructive-action guard; add
the same concurrent protection used by lib/codex-manager.ts (the
destructiveActionInFlight guard) around the index-level handlers that perform
destructive operations (the code paths that set startFresh and call
resetLocalState) so a second dispatch is serialized or rejected, and update
invalidateAccountManagerCache usage to run only after the guarded operation
completes; then add regression tests that double-trigger the index-level "reset"
and "fresh" paths to assert serialized/rejected behavior and that simulate
Windows EPERM/EBUSY during concurrent destructive operations to validate the
same lock handling seen in codex-manager and existing
destructive-actions.test.ts.

In `@lib/codex-manager.ts`:
- Around line 3817-3831: Before calling deleteAccountAtIndex in the menu
handling branch that checks menuResult.deleteAccountIndex, guard with the
destructiveActionInFlight flag: check it and set it true before invoking
deleteAccountAtIndex, then use try/finally to ensure destructiveActionInFlight
is reset to false after the call (or on error). Locate the menu branch that
reads menuResult.deleteAccountIndex and wrap the delete flow (the call to
deleteAccountAtIndex and subsequent Object.assign/logging) inside that
check/try/finally using the existing destructiveActionInFlight boolean used by
resetLocalState and deleteSavedAccounts so concurrent writes are prevented;
update tests to add a concurrency scenario exercising this race window.

In `@lib/destructive-actions.ts`:
- Around line 193-199: deleteSavedAccounts currently lets exceptions from
clearAccounts bubble up; wrap the call to clearAccounts in a try/catch so the
function returns a DestructiveActionResult with accountsCleared set to true on
success and false on failure (do not rethrow), matching the semantics used by
resetLocalState; keep the other fields (flaggedCleared, quotaCacheCleared)
unchanged and log or capture the error if needed for diagnostics inside the
catch block.
- Around line 205-215: resetLocalState currently swallows errors for each step
via .catch(() => false) which hides which operation failed; change it to call
each operation (clearAccounts, clearFlaggedAccounts, clearQuotaCache) inside its
own try/catch so failures are isolated, log the thrown error with context (e.g.,
"failed to clear accounts", "failed to clear flagged accounts", "failed to clear
quota cache") using your project's logger (e.g., processLogger) or
console.warn/error, ensure clearCodexCliStateCache still runs unconditionally,
and return the same DestructiveActionResult booleans based on success/failure of
each step.
- Around line 120-142: The manage-mode delete path is not protected by the
destructiveActionInFlight gate which allows concurrent calls to
deleteAccountAtIndex to race; update the manage-mode branch in
lib/codex-manager.ts (the block that calls handleManageAction()) to
acquire/check the destructiveActionInFlight flag the same way the "fresh" and
"reset" branches do, ensuring the flag is set before calling
handleManageAction() and cleared after, or alternatively add a concurrency test
in test/destructive-actions.test.ts that invokes deleteAccountAtIndex
concurrently to assert the race is prevented; reference
destructiveActionInFlight, handleManageAction, and deleteAccountAtIndex when
making the change or writing the test.

In `@lib/storage.ts`:
- Around line 2004-2023: The clearAccounts boolean helper is writing the reset
marker before ensuring the parent storage directory exists which can throw
ENOENT on empty/fresh installs; update clearAccounts (and the other boolean
clear helper around 2272-2287) to either short-circuit and return false when
there is nothing to clear (no WAL, backups, or legacy paths) or ensure the
parent directory exists (mkdirp/getStoragePath parent) before writing
getIntentionalResetMarkerPath; locate code using getStoragePath,
getIntentionalResetMarkerPath, getAccountsWalPath, and
getAccountsBackupRecoveryCandidatesWithDiscovery to implement the fix, add a
Vitest regression that runs the helper against an empty temp dir to assert no
exception and correct boolean return, and keep logging free of tokens/emails and
robust to Windows EBUSY semantics.
- Around line 2289-2294: The destructive sweep currently only deletes the
current flagged file and its backups (uses
getAccountsBackupRecoveryCandidatesWithDiscovery(path)) but misses the legacy
flagged file, so update the sweep in lib/storage.ts (the loop that calls
unlinkWithRetry on each candidate) to also include
getLegacyFlaggedAccountsPath() in the candidates array, ensuring the legacy path
is cleared alongside path and backupPaths; update the corresponding test
test/release-main-prs-regression.test.ts to add a case that creates the legacy
file (openai-codex-blocked-accounts.json) and asserts it is removed after the
sweep, and while touching unlinkWithRetry ensure retry/queue logic still handles
EBUSY/429 scenarios without leaking tokens or emails in logs.
- Around line 1857-1869: The cleanup after rename currently calls
fs.unlink(resetMarkerPath) and swallows errors, which leaves reset markers on
Windows; replace that bare unlink with the project's retry helper
unlinkWithRetry(resetMarkerPath) (mirroring the retry pattern used in
renameFileWithRetry) so failures like EPERM/EBUSY are retried before giving up,
preserving the existing "best effort" semantics; do the same for the walPath
removal (fs.unlink(walPath) -> unlinkWithRetry(walPath)) if you want symmetric
handling, and ensure tests exercise this behavior similarly to
test/quota-cache.test.ts (the retry-style coverage at lines ~108-184). Also keep
lastAccountsSaveTimestamp update and surrounding return logic unchanged.

In `@test/auth-menu-hotkeys.test.ts`:
- Around line 240-258: The tests "returns delete-all without an extra confirm
prompt" and "returns reset-all without an extra confirm prompt" only assert
confirmMock, which misses regressions that reintroduce typed confirmation via
the mocked node:readline/promises; update both specs to also assert that
searchQuestionMock and searchCloseMock were not called (e.g.,
expect(searchQuestionMock).not.toHaveBeenCalled();
expect(searchCloseMock).not.toHaveBeenCalled()) after calling showAuthMenu with
createAccounts(), referencing the existing mocks and the showAuthMenu import to
ensure typed-confirmation helpers in lib/ui/auth-menu.ts remain unused for these
destructive actions.

In `@test/codex-manager-cli.test.ts`:
- Around line 3506-3568: Add deterministic vitest cases to cover the
partial-cleanup warning branches in lib/codex-manager.ts by simulating cleanup
failure (the Windows ebusy/eperm path): create one test for the "delete saved
accounts" flow and one for the "reset local state" flow that mirror the existing
tests (use runCodexMultiAuthCli from lib/codex-manager.js, mock loadAccountsMock
and promptLoginModeMock the same way) but have deleteSavedAccountsMock or
resetLocalStateMock resolve/return false instead of success, then assert
exitCode === 0 and that console.log was called with the specific partial-cleanup
warning message emitted by the code paths (the messages around the
partial-cleanup branches referenced in lib/codex-manager.ts). Ensure tests
remain deterministic by using vitest mocks (no real secrets, no timers/races)
and restore spies after assertions.

In `@test/destructive-actions.test.ts`:
- Around line 299-360: The test for deleteAccountAtIndex currently asserts the
original flaggedSaveError and rollbackError are preserved, but the
implementation runs errors through asSafeAggregateError which may sanitize
path-like messages; update the test by making the mocked errors
(flaggedSaveError and rollbackError) include path characters (e.g. a string with
"/" or "\") so sanitization is exercised, and change the expectation on
aggregateError.errors to assert the sanitized errors/messages produced by
asSafeAggregateError (instead of the original Error objects) while keeping the
rest of the test (calls to saveAccountsMock and storage assertions) unchanged.

In `@test/storage.test.ts`:
- Around line 3362-3389: The test's mock relies on unlinkSpy.mock.calls.length
to detect the first call which is brittle; change the mock to use an independent
counter variable (e.g., callCount) captured in the test scope and increment it
inside the mockImplementation, then throw the transient error when callCount ===
1; update the test around unlinkSpy and its mock (the mockImplementation for
fs.unlink used in the "retries transient %s when clearing the quota cache" case
that exercises clearQuotaCache and getQuotaCachePath) and restore behavior the
same otherwise.

---

Outside diff comments:
In `@test/cli-auth-menu.test.ts`:
- Around line 282-295: The cancel-path tests for promptLoginMode (e.g., the
"cancels delete-all when typed confirmation is not DELETE" case that stubs
showAuthMenu and uses mockRl.question) do not assert that the readline handle
was closed; add an assertion that mockRl.close was called (e.g.,
expect(mockRl.close).toHaveBeenCalled()) after awaiting promptLoginMode in each
negative/ cancel flow test, and ensure any console spies or mocks are restored
afterwards so the readline mock cannot leak across tests.

In `@test/codex-manager-cli.test.ts`:
- Around line 456-567: The test suite leaves the process TTY in an interactive
state causing non-deterministic hangs via runActionPanel()/waitForMenuReturn;
add a deterministic default by calling setInteractiveTTY(false) in the
beforeEach setup (alongside the existing restoreTTYDescriptors() call) so tests
default to non-interactive; keep setupInteractiveSettingsLogin() as the only
place that opts into true. Reference: the test file's beforeEach block and the
helpers setInteractiveTTY and setupInteractiveSettingsLogin (and the runtime
paths runActionPanel / waitForMenuReturn) to locate where to insert the call.
- Around line 3391-3449: The test currently pins loadAccountsMock to a static
pre-delete pool so it only verifies deleteAccountAtIndexMock was called; change
the mock to use mutable state (e.g., a local in-test variable representing
current storage) and have loadAccountsMock return that state on each call,
update that state when deleteAccountAtIndexMock is invoked (or make
deleteAccountAtIndexMock resolve to the new storage), then after
runCodexMultiAuthCli(["auth","login"]) assert the next menu/render reflects the
rebased one-account pool (verify loadAccountsMock returned the post-deletion
storage and the UI/menu contains only the remaining email) instead of only
checking deleteAccountAtIndexMock call; keep using vitest-style mocks and
assertions (loadAccountsMock, deleteAccountAtIndexMock, promptLoginModeMock,
runCodexMultiAuthCli).

In `@test/storage-flagged.test.ts`:
- Around line 15-16: The test matrix is missing coverage for the ENOTEMPTY case
even though RETRYABLE_REMOVE_CODES includes it; update the parameterized tests
that iterate over retryable error codes (the test using RETRYABLE_REMOVE_CODES
or the array of codes in the parameterized test) to include "ENOTEMPTY"
alongside "EPERM" and "EBUSY" so the behavior for ENOTEMPTY is asserted the same
as the other retryable codes; locate the parameterized test in
storage-flagged.test.ts that references EPERM/EBUSY and add ENOTEMPTY to that
list so the test exercises the same retry logic as the RETRYABLE_REMOVE_CODES
constant.
🪄 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: a7b63af9-aa4b-4696-8a99-59988db5789a

📥 Commits

Reviewing files that changed from the base of the PR and between 9ab3669 and 87eace8.

📒 Files selected for processing (19)
  • README.md
  • docs/troubleshooting.md
  • index.ts
  • lib/cli.ts
  • lib/codex-manager.ts
  • lib/destructive-actions.ts
  • lib/quota-cache.ts
  • lib/storage.ts
  • lib/ui/auth-menu.ts
  • lib/ui/copy.ts
  • test/auth-menu-hotkeys.test.ts
  • test/cli-auth-menu.test.ts
  • test/cli.test.ts
  • test/codex-manager-cli.test.ts
  • test/destructive-actions.test.ts
  • test/quota-cache.test.ts
  • test/release-main-prs-regression.test.ts
  • test/storage-flagged.test.ts
  • test/storage.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)
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/cli-auth-menu.test.ts
  • test/release-main-prs-regression.test.ts
  • test/auth-menu-hotkeys.test.ts
  • test/storage-flagged.test.ts
  • test/destructive-actions.test.ts
  • test/cli.test.ts
  • test/codex-manager-cli.test.ts
  • test/quota-cache.test.ts
  • test/storage.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.ts
  • lib/quota-cache.ts
  • lib/destructive-actions.ts
  • lib/codex-manager.ts
  • lib/ui/copy.ts
  • lib/ui/auth-menu.ts
  • lib/cli.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/troubleshooting.md
🔇 Additional comments (23)
lib/destructive-actions.ts (3)

174-178: path leakage on successful rollback path.

when saveFlaggedAccounts fails and rollback succeeds, asSafeAggregateError is called at line 174, but the original error is already captured at line 153 via asError. if the original error message contains a path, that path is preserved in originalError and only sanitized when re-wrapping at line 174.

however, looking closer: asSafeAggregateError creates a new error with fallbackMessage when paths are detected, so the sanitized version is what's thrown. this looks correct.

one edge case: if originalError.message contains a path but asSafeAggregateError is called with a different fallbackMessage that doesn't match the original context, the user loses the original error code context. the code preserves errorCode though, so this is fine.


38-69: clampActiveIndices handles edge cases well.

the function correctly:

  • handles empty accounts array (lines 46-50)
  • validates activeIndex is finite (lines 41-44)
  • clamps per-family indices with proper fallback (lines 54-68)

looks solid for the index management logic.


71-86: rebaseActiveIndicesAfterDelete only decrements indices above removedIndex.

this is correct behavior - indices at or below the removed position stay the same (except the removed one is gone), indices above shift down by 1.

note: if activeIndex === removedIndex, it stays unchanged here but clampActiveIndices is called after (line 140) to fix it. that's the right pattern.

docs/troubleshooting.md (2)

92-104: documentation accurately reflects the new destructive action semantics.

the exact effects table clearly documents what each action does. this matches the implementation in lib/destructive-actions.ts:

  • deleteSavedAccounts only clears accounts (line 195 sets flaggedCleared: false)
  • resetLocalState clears accounts, flagged, and quota cache (lines 206-208)

good alignment between docs and code.


110-120: manual cleanup instructions are consistent with implementation.

the bash/powershell commands now include .wal and .bak* artifacts, which matches the clearAccounts implementation that removes these files. as per coding guidelines, docs should stay consistent with actual CLI behavior - this looks aligned.

test/storage-flagged.test.ts (2)

235-277: good coverage for transient EPERM/EBUSY retry behavior.

the parameterized tests at lib/storage-flagged.test.ts:235-277 properly verify:

  • first unlink fails with transient error
  • second attempt succeeds
  • file is removed
  • exactly 2 unlink calls on the primary path

this covers the windows filesystem edge cases mentioned in coding guidelines.


279-322: exhausted retry tests verify correct failure semantics.

tests at lib/storage-flagged.test.ts:279-322 confirm that when all 5 retry attempts fail:

  • clearFlaggedAccounts() returns false (not throws)
  • primary file remains on disk
  • exactly 5 unlink attempts recorded

this matches the boolean-return API contract.

test/storage.test.ts (3)

2516-2544: good test for clearing legacy worktree storage files.

test/storage.test.ts:2516-2544 verifies that clearAccounts removes:

  • canonical path
  • legacy project path
  • legacy worktree path

this is important for the migration path and prevents orphaned files.


2832-2861: EAGAIN retry test with WAL cleanup is a good addition.

this test at test/storage.test.ts:2832-2861 covers:

  • EAGAIN as retryable error (distinct from EPERM/EBUSY)
  • WAL file is cleaned up after successful save
  • exactly 2 rename attempts

solid coverage for the retry matrix.


3322-3420: comprehensive clearQuotaCache test coverage.

the new test block properly covers:

  • basic file removal
  • tolerance for missing files
  • EPERM/EBUSY retry behavior
  • exhausted retry returning false

this aligns with the boolean-return API established for clear operations.

test/destructive-actions.test.ts (3)

243-297: path redaction test verifies windows paths are sanitized.

the test at test/destructive-actions.test.ts:243-297 correctly verifies that:

  • error message doesn't contain user-identifying paths (alice)
  • error code is preserved
  • fallback message is used

this is important for privacy on windows systems.


96-142: rebase index test covers the critical deletion edge case.

test/destructive-actions.test.ts:96-142 verifies that when deleting account at index 0:

  • activeIndex shifts from 1 to 0
  • activeIndexByFamily values shift correctly (codex: 2→1, gpt-5.x: 1→0)
  • remaining accounts are correct

this is the key logic that prevents stale indices after deletion.


77-94: best-effort reset test validates partial failure handling.

test/destructive-actions.test.ts:77-94 confirms that when clearFlaggedAccounts throws:

  • resetLocalState still returns (doesn't throw)
  • flaggedCleared is false
  • other steps complete normally

this matches the best-effort semantics documented in the PR.

lib/cli.ts (4)

161-171: promptResetTypedConfirm follows established pattern.

the new function at lib/cli.ts:161-171 mirrors promptDeleteAllTypedConfirm structure:

  • creates readline interface
  • prompts with DESTRUCTIVE_ACTION_COPY text
  • checks exact match ("RESET")
  • closes readline in finally

consistent and correct.


278-283: reset-all flow integrated correctly into promptLoginMode.

the new case at lib/cli.ts:278-283 handles the TTY menu reset-all action:

  • prompts for typed confirmation
  • logs cancellation message if not confirmed
  • returns { mode: "reset" } on success

this follows the same pattern as the fresh case.


23-24: TERM_PROGRAM check uses case-insensitive comparison.

lib/cli.ts:23-24 normalizes TERM_PROGRAM with .trim().toLowerCase() before comparing to "codex". this handles variations like "Codex", " codex ", etc.


49-61: LoginMode type extended with "reset".

the type union at lib/cli.ts:49-61 now includes "reset" alongside existing modes. the union is exhaustive for the switch cases in promptLoginMode.

test/cli.test.ts (4)

176-185: reset flow test covers fallback confirmation.

test/cli.test.ts:176-185 verifies:

  • "reset" input triggers reset flow
  • "RESET" typed confirmation proceeds
  • returns { mode: "reset" }

good coverage for the non-TTY fallback path.


203-217: reset cancellation test verifies user-facing message.

test/cli.test.ts:203-217 confirms that when typed confirmation doesn't match:

  • flow continues to next prompt
  • cancellation message is logged: "\nReset local state cancelled.\n"

this matches the UX established for delete-all cancellation.


219-266: TTY reset-all test mocks showAuthMenu correctly.

test/cli.test.ts:219-266 sets up:

  • stdin/stdout isTTY overrides
  • showAuthMenu mock returning { type: "reset-all" }
  • readline mock for "RESET" confirmation
  • proper cleanup in finally block

the test isolation is good - vi.doMock/vi.doUnmock pattern prevents leakage.


268-321: TTY reset-all cancellation test verifies correct copy.

test/cli.test.ts:268-321 confirms that when TTY reset-all is selected but typed confirmation fails:

  • cancellation message "\nReset local state cancelled.\n" is logged
  • flow returns to menu (mock returns { type: "add" } on second call)

this ensures the UX is consistent between fallback and TTY paths.

index.ts (2)

104-109: good consolidation of destructive-action imports.

this keeps destructive behavior anchored to the shared safety layer in lib/destructive-actions.ts:1, which is the right direction for this flow.


3151-3158: good use of centralized delete flow for fresh mode.

delegating to deleteSavedAccounts and reusing DESTRUCTIVE_ACTION_COPY keeps behavior and user copy consistent with lib/destructive-actions.ts:1.

Comment on lines +240 to +258
it("returns delete-all without an extra confirm prompt", async () => {
selectMock.mockResolvedValueOnce({ type: "delete-all" });

const { showAuthMenu } = await import("../lib/ui/auth-menu.js");
const result = await showAuthMenu(createAccounts());

expect(result).toEqual({ type: "delete-all" });
expect(confirmMock).not.toHaveBeenCalled();
});

it("returns reset-all without an extra confirm prompt", async () => {
selectMock.mockResolvedValueOnce({ type: "reset-all" });

const { showAuthMenu } = await import("../lib/ui/auth-menu.js");
const result = await showAuthMenu(createAccounts());

expect(result).toEqual({ type: "reset-all" });
expect(confirmMock).not.toHaveBeenCalled();
});
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

assert that the menu never opens typed confirmation.

test/auth-menu-hotkeys.test.ts:240-258 only checks confirmMock. this file already mocks node:readline/promises at test/auth-menu-hotkeys.test.ts:17-22, so a regression that moves typed confirmation back into lib/ui/auth-menu.ts would still pass. assert searchQuestionMock and searchCloseMock stay untouched for both destructive actions.

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. reject changes that mock real secrets or skip assertions.

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

In `@test/auth-menu-hotkeys.test.ts` around lines 240 - 258, The tests "returns
delete-all without an extra confirm prompt" and "returns reset-all without an
extra confirm prompt" only assert confirmMock, which misses regressions that
reintroduce typed confirmation via the mocked node:readline/promises; update
both specs to also assert that searchQuestionMock and searchCloseMock were not
called (e.g., expect(searchQuestionMock).not.toHaveBeenCalled();
expect(searchCloseMock).not.toHaveBeenCalled()) after calling showAuthMenu with
createAccounts(), referencing the existing mocks and the showAuthMenu import to
ensure typed-confirmation helpers in lib/ui/auth-menu.ts remain unused for these
destructive actions.

Comment on lines +299 to +360
it("preserves both the flagged-save failure and rollback failure", async () => {
const flaggedSaveError = Object.assign(new Error("flagged save failed"), {
code: "EPERM",
});
const rollbackError = Object.assign(new Error("rollback failed"), {
code: "EPERM",
});
saveAccountsMock
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(rollbackError);
saveFlaggedAccountsMock.mockRejectedValueOnce(flaggedSaveError);
loadFlaggedAccountsMock.mockResolvedValue({
version: 1,
accounts: [
{
refreshToken: "refresh-remove",
addedAt: 1,
lastUsed: 1,
flaggedAt: 1,
},
],
});

const { deleteAccountAtIndex } = await import(
"../lib/destructive-actions.js"
);

const storage = {
version: 3,
activeIndex: 0,
activeIndexByFamily: { codex: 0 },
accounts: [
{
refreshToken: "refresh-keep",
addedAt: 1,
lastUsed: 1,
},
{
refreshToken: "refresh-remove",
addedAt: 2,
lastUsed: 2,
},
],
};

try {
await deleteAccountAtIndex({ storage, index: 1 });
throw new Error("expected deleteAccountAtIndex to throw");
} catch (error) {
expect(error).toBeInstanceOf(AggregateError);
const aggregateError = error as AggregateError;
expect(aggregateError.message).toBe(
"Deleting the account partially failed and rollback also failed.",
);
expect(aggregateError.errors).toEqual([
flaggedSaveError,
rollbackError,
]);
}
expect(saveAccountsMock).toHaveBeenCalledTimes(2);
expect(storage.accounts).toHaveLength(2);
});
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

test expects original errors in AggregateError but implementation sanitizes them.

at test/destructive-actions.test.ts:353-356, the test expects:

expect(aggregateError.errors).toEqual([
  flaggedSaveError,
  rollbackError,
]);

but looking at lib/destructive-actions.ts:160-170, the implementation wraps errors with asSafeAggregateError, which creates new Error objects when paths are detected. the original errors shouldn't be in the array.

compare with the test at lines 362-431 which correctly expects sanitized error messages. this test at 299-360 may be passing because the mock errors don't contain path separators, so asSafeAggregateError returns them unchanged.

consider adding path characters to the mock errors to verify sanitization:

suggested fix
-const flaggedSaveError = Object.assign(new Error("flagged save failed"), {
+const flaggedSaveError = Object.assign(new Error("flagged save failed at /path/to/file"), {
   code: "EPERM",
 });
-const rollbackError = Object.assign(new Error("rollback failed"), {
+const rollbackError = Object.assign(new Error("rollback failed at /path/to/backup"), {
   code: "EPERM",
 });

then update the assertion to check for sanitized messages.

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

In `@test/destructive-actions.test.ts` around lines 299 - 360, The test for
deleteAccountAtIndex currently asserts the original flaggedSaveError and
rollbackError are preserved, but the implementation runs errors through
asSafeAggregateError which may sanitize path-like messages; update the test by
making the mocked errors (flaggedSaveError and rollbackError) include path
characters (e.g. a string with "/" or "\") so sanitization is exercised, and
change the expectation on aggregateError.errors to assert the sanitized
errors/messages produced by asSafeAggregateError (instead of the original Error
objects) while keeping the rest of the test (calls to saveAccountsMock and
storage assertions) unchanged.

Comment on lines +3362 to +3389
it.each(["EPERM", "EBUSY"] as const)(
"retries transient %s when clearing the quota cache",
async (code) => {
const quotaPath = getQuotaCachePath();
await fs.mkdir(dirname(quotaPath), { recursive: true });
await fs.writeFile(quotaPath, "{}", "utf-8");

const realUnlink = fs.unlink.bind(fs);
const unlinkSpy = vi
.spyOn(fs, "unlink")
.mockImplementation(async (target) => {
if (target === quotaPath && unlinkSpy.mock.calls.length === 1) {
const err = new Error("locked") as NodeJS.ErrnoException;
err.code = code;
throw err;
}
return realUnlink(target);
});

try {
await expect(clearQuotaCache()).resolves.toBe(true);
expect(existsSync(quotaPath)).toBe(false);
expect(unlinkSpy).toHaveBeenCalledTimes(2);
} finally {
unlinkSpy.mockRestore();
}
},
);
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

fragile first-call check in retry test.

at test/storage.test.ts:3373, the condition unlinkSpy.mock.calls.length === 1 checks if this is the first call, but this relies on the mock being called exactly once before the check. if the implementation changes to make multiple unlink calls per attempt, this could break unexpectedly.

consider using a counter variable similar to the pattern in other tests:

suggested approach
 const realUnlink = fs.unlink.bind(fs);
+let failedOnce = false;
 const unlinkSpy = vi
 	.spyOn(fs, "unlink")
 	.mockImplementation(async (target) => {
-		if (target === quotaPath && unlinkSpy.mock.calls.length === 1) {
+		if (target === quotaPath && !failedOnce) {
+			failedOnce = true;
 			const err = new Error("locked") as NodeJS.ErrnoException;
 			err.code = code;
 			throw err;
 		}
 		return realUnlink(target);
 	});
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it.each(["EPERM", "EBUSY"] as const)(
"retries transient %s when clearing the quota cache",
async (code) => {
const quotaPath = getQuotaCachePath();
await fs.mkdir(dirname(quotaPath), { recursive: true });
await fs.writeFile(quotaPath, "{}", "utf-8");
const realUnlink = fs.unlink.bind(fs);
const unlinkSpy = vi
.spyOn(fs, "unlink")
.mockImplementation(async (target) => {
if (target === quotaPath && unlinkSpy.mock.calls.length === 1) {
const err = new Error("locked") as NodeJS.ErrnoException;
err.code = code;
throw err;
}
return realUnlink(target);
});
try {
await expect(clearQuotaCache()).resolves.toBe(true);
expect(existsSync(quotaPath)).toBe(false);
expect(unlinkSpy).toHaveBeenCalledTimes(2);
} finally {
unlinkSpy.mockRestore();
}
},
);
it.each(["EPERM", "EBUSY"] as const)(
"retries transient %s when clearing the quota cache",
async (code) => {
const quotaPath = getQuotaCachePath();
await fs.mkdir(dirname(quotaPath), { recursive: true });
await fs.writeFile(quotaPath, "{}", "utf-8");
const realUnlink = fs.unlink.bind(fs);
let failedOnce = false;
const unlinkSpy = vi
.spyOn(fs, "unlink")
.mockImplementation(async (target) => {
if (target === quotaPath && !failedOnce) {
failedOnce = true;
const err = new Error("locked") as NodeJS.ErrnoException;
err.code = code;
throw err;
}
return realUnlink(target);
});
try {
await expect(clearQuotaCache()).resolves.toBe(true);
expect(existsSync(quotaPath)).toBe(false);
expect(unlinkSpy).toHaveBeenCalledTimes(2);
} finally {
unlinkSpy.mockRestore();
}
},
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/storage.test.ts` around lines 3362 - 3389, The test's mock relies on
unlinkSpy.mock.calls.length to detect the first call which is brittle; change
the mock to use an independent counter variable (e.g., callCount) captured in
the test scope and increment it inside the mockImplementation, then throw the
transient error when callCount === 1; update the test around unlinkSpy and its
mock (the mockImplementation for fs.unlink used in the "retries transient %s
when clearing the quota cache" case that exercises clearQuotaCache and
getQuotaCachePath) and restore behavior the same otherwise.

Copy link
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: 4

Caution

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

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

2273-2323: ⚠️ Potential issue | 🟠 Major

honor the reset marker before legacy flagged migration.

when unlinkWithRetry(legacyPath) exhausts retries here, this branch intentionally keeps markerPath, but loadFlaggedAccounts() only consults that marker after a successful primary read in lib/storage.ts:2164-2218. if the primary is gone and the legacy file survives, lib/storage.ts:2188-2210 migrates openai-codex-blocked-accounts.json right back on the next load. test/storage-flagged.test.ts:395-423 only proves the happy path, so this windows failure mode is still untested.

possible fix
 export async function loadFlaggedAccounts(): Promise<FlaggedAccountStorageV1> {
 	const path = getFlaggedAccountsPath();
 	const resetMarkerPath = getIntentionalResetMarkerPath(path);
 	const empty: FlaggedAccountStorageV1 = { version: 1, accounts: [] };
+	if (existsSync(resetMarkerPath)) {
+		return empty;
+	}
 
 	try {
 		const content = await fs.readFile(path, "utf-8");
 		const data = JSON.parse(content) as unknown;
 		const loaded = normalizeFlaggedStorage(data);
-		if (existsSync(resetMarkerPath)) {
-			return empty;
-		}
 		return loaded;

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 AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/storage.ts` around lines 2273 - 2323, The current bug is that
clearFlaggedAccounts may leave the reset marker when deleting legacyPath fails,
but loadFlaggedAccounts only checks the marker after a successful primary read
which allows a surviving legacy file to be re-migrated; fix by making
loadFlaggedAccounts consult the intentional reset marker
(getIntentionalResetMarkerPath(path) / markerPath) before performing any legacy
migration or fallback reads (i.e., check marker existence early in
loadFlaggedAccounts and abort/mask migration if present), update references to
unlinkWithRetry/legacyPath/path within that logic, and add a vitest in
test/storage-flagged.test.ts that simulates primary missing + legacy present +
marker present to assert no migration occurs.

2004-2049: ⚠️ Potential issue | 🟠 Major

honor the reset marker before legacy account migration.

if one of the legacyPaths deletes fails here, clearAccounts() keeps the reset marker and returns false, but loadAccountsInternal() still runs migrateLegacyProjectStorageIfNeeded() first in lib/storage.ts:856-952 and lib/storage.ts:1618-1627. in project-scoped mode that migration persists the surviving legacy file through saveAccountsUnlocked() and removes the marker at lib/storage.ts:1857-1866, so the next load can silently recreate the account pool you just tried to delete. please skip legacy migration whenever the reset marker exists, and add a vitest regression in test/storage.test.ts that forces one legacy unlink to stay locked.

possible fix
 async function migrateLegacyProjectStorageIfNeeded(
 	persist: (storage: AccountStorageV3) => Promise<void> = saveAccounts,
 ): Promise<AccountStorageV3 | null> {
 	if (!currentStoragePath) {
 		return null;
 	}
+	if (existsSync(getIntentionalResetMarkerPath(currentStoragePath))) {
+		return null;
+	}
 
 	const candidatePaths = [

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 AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/storage.ts` around lines 2004 - 2049, clearAccounts writes an intentional
reset marker then tries to remove legacyPaths, but if a legacy unlink fails the
marker can persist while loadAccountsInternal still runs
migrateLegacyProjectStorageIfNeeded and resurrects accounts; change
migrateLegacyProjectStorageIfNeeded (called from loadAccountsInternal) to first
check for the intentional reset marker (use
getIntentionalResetMarkerPath(getStoragePath())) and early-return / skip any
legacy migration when the marker exists, and keep clearAccounts behavior so
failures still return false; add a vitest regression that simulates one legacy
unlink staying locked (causing unlink to fail) and asserts that migration is
skipped, the reset marker remains, and no account pool is recreated.
♻️ Duplicate comments (2)
test/codex-manager-cli.test.ts (1)

477-490: ⚠️ Potential issue | 🟠 Major

cover the partial-cleanup warning branches for delete and reset.

test/codex-manager-cli.test.ts:477-490 hard-wires both destructive actions to all-success results, and the new flows at test/codex-manager-cli.test.ts:3607-3669 never override that. lib/destructive-actions.ts:193-215 exposes accountsCleared, flaggedCleared, and quotaCacheCleared specifically so the cli can surface partial-cleanup warnings. without one false case for deleteSavedAccounts() and one for resetLocalState(), the windows eperm/ebusy warning copy can still regress unnoticed.

As per coding guidelines test/**: demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior.

Also applies to: 3607-3669

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

In `@test/codex-manager-cli.test.ts` around lines 477 - 490, The tests currently
stub deleteSavedAccountsMock and resetLocalStateMock to always return all-true
results, so the CLI partial-cleanup warning branches in
lib/destructive-actions.ts (which check accountsCleared, flaggedCleared,
quotaCacheCleared) are not exercised; update the test cases around
deleteSavedAccountsMock, resetLocalStateMock and deleteAccountAtIndexMock so
that at least one test simulates deleteSavedAccounts() returning one false
(e.g., flaggedCleared:false or quotaCacheCleared:false) and another simulates
resetLocalState() returning one false, to force the partial-cleanup warning
paths to run (keep using the same mocks deleteSavedAccountsMock,
resetLocalStateMock, deleteAccountAtIndexMock and adjust mockResolvedValue
returns accordingly).
test/storage.test.ts (1)

3456-3483: ⚠️ Potential issue | 🟡 Minor

make the first retry failure independent of total unlink calls.

test/storage.test.ts:3467 still keys the injected failure off unlinkSpy.mock.calls.length === 1. that makes the case brittle: any extra fs.unlink before the quota-path attempt will stop reproducing the retry path.

suggested fix
 				const realUnlink = fs.unlink.bind(fs);
+				let failedOnce = false;
 				const unlinkSpy = vi
 					.spyOn(fs, "unlink")
 					.mockImplementation(async (target) => {
-						if (target === quotaPath && unlinkSpy.mock.calls.length === 1) {
+						if (target === quotaPath && !failedOnce) {
+							failedOnce = true;
 							const err = new Error("locked") as NodeJS.ErrnoException;
 							err.code = code;
 							throw err;
 						}

As per coding guidelines test/**: tests must stay deterministic and use vitest.

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

In `@test/storage.test.ts` around lines 3456 - 3483, The injected transient
failure in the test is keyed to unlinkSpy.mock.calls.length which makes the
failure depend on total unlink calls; change the mock in the test for unlink
(the unlinkSpy mock around fs.unlink used in the "retries transient %s when
clearing the quota cache" case) to detect the specific target path (quotaPath)
and a per-target first-call state (e.g., a local boolean like
`failedOnceForQuotaPath`) rather than global mock.calls length, so the mock
throws the ErrnoException for the first attempt to unlink quotaPath and succeeds
thereafter, keeping the test deterministic for clearQuotaCache and expectations
about unlinkSpy call behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/codex-manager.ts`:
- Around line 3817-3841: After deleting an account and merging deleted.storage
into storage in the deleteAccountIndex branch, ensure codex is resynced: call
autoSyncActiveAccountToCodex() (or explicitly clear codex credentials if the
deleted index was the active account) immediately after Object.assign(storage,
deleted.storage) and before logging/return, so codex won't reference removed
credentials; update lib/codex-manager.ts around the deleteAccountAtIndex
handling to perform this resync/clear and ensure the call is awaited. Add a
vitest regression in test/codex-manager-cli.test.ts that deletes the currently
active account and asserts codex state is cleared or updated appropriately. When
implementing, follow lib/** guidelines: avoid logging sensitive tokens/emails,
handle concurrent destructiveActionInFlight correctly, and ensure any new queues
or retries used by autoSyncActiveAccountToCodex tolerate EBUSY/429 conditions.

In `@test/codex-manager-cli.test.ts`:
- Around line 3671-3753: The test currently only delays
fetchCodexQuotaSnapshotMock but never delays the quota write; to reproduce the
race, make saveQuotaCacheMock return a deferred promise (like fetchDeferred) so
the save stays in-flight when resetLocalStateMock is invoked, resolve that
deferred only after observing reset started, and then assert that the save's
invocation completed before clearQuotaCache() / resetLocalStateMock clears the
cache; update the test setup to create a saveDeferred, have
saveQuotaCacheMock.mockImplementation(async () => saveDeferred.promise),
coordinate fetchDeferred and saveDeferred ordering around runCodexMultiAuthCli
and then assert invocationCallOrder between saveQuotaCacheMock and
resetLocalStateMock (and/or that clearQuotaCache was not overlapped).

In `@test/storage-flagged.test.ts`:
- Around line 235-322: The tests exercise unlinkWithRetry's EPERM/EBUSY retry
branch but miss the EAGAIN branch; update both it.each matrices in
test/storage-flagged.test.ts that currently use ["EPERM","EBUSY"] to include
"EAGAIN" (i.e. ["EPERM","EBUSY","EAGAIN"]) so clearFlaggedAccounts() triggers
unlinkWithRetry()'s EAGAIN retry logic; keep the same mockImplementation
behavior and assertions (unlinkSpy, existsSync, call counts) so the tests remain
deterministic and cover the additional branch.

In `@test/storage.test.ts`:
- Around line 2563-2591: The new test case for clearAccounts is missing a
Windows-style linked-worktree variant, so add a regression that uses the Windows
`.git` pointer form (like the other windows fixtures around lines 2393-2450 and
2698-2733) to exercise path normalization; specifically, create or reuse a
fixture that produces a win32 linked worktree (e.g., prepareWorktreeFixture or a
new prepareWindowsLinkedWorktreeFixture), call setStoragePath(worktreeRepo) and
build/write the same canonical, legacy project and legacy worktree files, then
call clearAccounts() and assert all three files were removed; ensure you
reference the same helper functions used elsewhere (prepareWorktreeFixture,
setStoragePath, getProjectStorageKey, getConfigDir, clearAccounts) so the test
hits the Windows pointer code path and catches the regression.

---

Outside diff comments:
In `@lib/storage.ts`:
- Around line 2273-2323: The current bug is that clearFlaggedAccounts may leave
the reset marker when deleting legacyPath fails, but loadFlaggedAccounts only
checks the marker after a successful primary read which allows a surviving
legacy file to be re-migrated; fix by making loadFlaggedAccounts consult the
intentional reset marker (getIntentionalResetMarkerPath(path) / markerPath)
before performing any legacy migration or fallback reads (i.e., check marker
existence early in loadFlaggedAccounts and abort/mask migration if present),
update references to unlinkWithRetry/legacyPath/path within that logic, and add
a vitest in test/storage-flagged.test.ts that simulates primary missing + legacy
present + marker present to assert no migration occurs.
- Around line 2004-2049: clearAccounts writes an intentional reset marker then
tries to remove legacyPaths, but if a legacy unlink fails the marker can persist
while loadAccountsInternal still runs migrateLegacyProjectStorageIfNeeded and
resurrects accounts; change migrateLegacyProjectStorageIfNeeded (called from
loadAccountsInternal) to first check for the intentional reset marker (use
getIntentionalResetMarkerPath(getStoragePath())) and early-return / skip any
legacy migration when the marker exists, and keep clearAccounts behavior so
failures still return false; add a vitest regression that simulates one legacy
unlink staying locked (causing unlink to fail) and asserts that migration is
skipped, the reset marker remains, and no account pool is recreated.

---

Duplicate comments:
In `@test/codex-manager-cli.test.ts`:
- Around line 477-490: The tests currently stub deleteSavedAccountsMock and
resetLocalStateMock to always return all-true results, so the CLI
partial-cleanup warning branches in lib/destructive-actions.ts (which check
accountsCleared, flaggedCleared, quotaCacheCleared) are not exercised; update
the test cases around deleteSavedAccountsMock, resetLocalStateMock and
deleteAccountAtIndexMock so that at least one test simulates
deleteSavedAccounts() returning one false (e.g., flaggedCleared:false or
quotaCacheCleared:false) and another simulates resetLocalState() returning one
false, to force the partial-cleanup warning paths to run (keep using the same
mocks deleteSavedAccountsMock, resetLocalStateMock, deleteAccountAtIndexMock and
adjust mockResolvedValue returns accordingly).

In `@test/storage.test.ts`:
- Around line 3456-3483: The injected transient failure in the test is keyed to
unlinkSpy.mock.calls.length which makes the failure depend on total unlink
calls; change the mock in the test for unlink (the unlinkSpy mock around
fs.unlink used in the "retries transient %s when clearing the quota cache" case)
to detect the specific target path (quotaPath) and a per-target first-call state
(e.g., a local boolean like `failedOnceForQuotaPath`) rather than global
mock.calls length, so the mock throws the ErrnoException for the first attempt
to unlink quotaPath and succeeds thereafter, keeping the test deterministic for
clearQuotaCache and expectations about unlinkSpy call behavior.
🪄 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: f3edf33b-d27f-4bd5-bb93-cdfc8e9afc5e

📥 Commits

Reviewing files that changed from the base of the PR and between 87eace8 and 7098a13.

📒 Files selected for processing (5)
  • lib/codex-manager.ts
  • lib/storage.ts
  • test/codex-manager-cli.test.ts
  • test/storage-flagged.test.ts
  • test/storage.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/storage-flagged.test.ts
  • test/storage.test.ts
  • test/codex-manager-cli.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.ts
  • lib/codex-manager.ts
🔇 Additional comments (1)
lib/storage.ts (1)

182-200: good windows cleanup hardening.

centralizing retryable unlinks and reusing them for the post-rename reset-marker and wal cleanup is the right fix for the stale-artifact window on windows. the transient lock handling shape is now at least partially exercised by test/storage-flagged.test.ts:235-322.

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.

Also applies to: 1857-1869

Copy link
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

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

Inline comments:
In `@lib/codex-manager.ts`:
- Around line 3970-3993: The delete-all branch doesn't await
pendingMenuQuotaRefresh before calling deleteSavedAccounts, risking a
quota-cache race; update the destructive "fresh && deleteAll" path (inside the
runActionPanel callback where deleteSavedAccounts() is invoked) to first await
pendingMenuQuotaRefresh (same as the reset path does) before calling
deleteSavedAccounts(), keeping the existing destructiveActionInFlight flag and
try/finally behavior intact.

In `@lib/quota-cache.ts`:
- Around line 41-50: The logs for filesystem errors in quota cache functions are
currently using raw error.message and may leak absolute paths; replace those
usages so all filesystem errors are sanitized through the existing helper
formatSafeQuotaCacheClearError(error). Specifically, update the error logging in
the functions that call fs.readFile/fs.rename/clearQuotaCache (eg. the
clearQuotaCache function and the load/save quota cache blocks that currently
append error.message) to call formatSafeQuotaCacheClearError(error) instead of
using error.message directly, and ensure any log lines or thrown messages use
that sanitized string; update related tests to simulate real path-bearing fs
errors so the sanitizer is exercised.
- Around line 34-39: Add "EAGAIN" to the RETRYABLE_FS_CODES set and ensure
isRetryableFsError recognizes it (update the RETRYABLE_FS_CODES constant used by
isRetryableFsError), then update the quota-cache tests that cover the
rename/unlink retry loops (the tests in test/quota-cache.test.ts that currently
assert behavior for EBUSY/EPERM) to also simulate and assert the same retry
behavior for EAGAIN so the quota-cache rename/unlink logic mirrors
lib/storage.ts's retry handling.
🪄 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: 2a5a1651-464b-46d9-8442-ae6964474437

📥 Commits

Reviewing files that changed from the base of the PR and between 7098a13 and 5eb23bf.

📒 Files selected for processing (7)
  • index.ts
  • lib/codex-manager.ts
  • lib/destructive-actions.ts
  • lib/quota-cache.ts
  • test/codex-manager-cli.test.ts
  • test/destructive-actions.test.ts
  • test/quota-cache.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/quota-cache.ts
  • lib/codex-manager.ts
  • lib/destructive-actions.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-cli.test.ts
  • test/quota-cache.test.ts
  • test/destructive-actions.test.ts
🔇 Additional comments (11)
test/codex-manager-cli.test.ts (2)

3722-3750: add the partial-cleanup warning regressions here.

test/codex-manager-cli.test.ts:3722-3784 (Lines 3722-3784) only asserts the all-success copy. if deleteSavedAccountsMock or resetLocalStateMock returns a false cleanup flag, the windows best-effort branch still has no assertion and the warning copy can regress unnoticed.

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. reject changes that mock real secrets or skip assertions.

Also applies to: 3754-3784


3786-3868: this still does not reproduce the quota-cache write/reset race.

test/codex-manager-cli.test.ts:3786-3868 (Lines 3786-3868) blocks fetchCodexQuotaSnapshotMock, but saveQuotaCacheMock resolves immediately. that only proves reset waits for the fetch. it does not prove reset waits for the cache write that can overlap clearQuotaCache(). keep saveQuotaCacheMock pending on a deferred promise and release it only after asserting resetLocalStateMock has not started.

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. reject changes that mock real secrets or skip assertions.

lib/codex-manager.ts (4)

3820-3843: lgtm - guard and resync look correct.

the destructiveActionInFlight guard at lib/codex-manager.ts:3820-3824 with try/finally at 3825-3843 serializes the delete flow. autoSyncActiveAccountToCodex() at line 3832 ensures codex points at valid credentials after removal, which addresses the earlier resync concern.


3995-4024: lgtm - reset flow correctly awaits quota refresh before clearing state.

the await at lib/codex-manager.ts:4006-4008 ensures the quota refresh completes before resetLocalState clears files, avoiding mid-write corruption. the guard pattern and finally block are correct.


33-38: lgtm - imports align with usage.


95-96: module-level guard prevents concurrent actions within single cli invocation; add regression test for guard behavior.

destructiveActionInFlight at lib/codex-manager.ts:95 serializes destructive actions within a single process, but concurrent cli processes can still race (documented gap in pr objectives).

however, two real issues need addressing:

  1. missing regression test for concurrent guard: coding guidelines require "verify every change cites affected tests (vitest)." test/destructive-actions.test.ts and test/codex-manager-cli.test.ts test individual function behavior but do not test that destructiveActionInFlight actually blocks a second concurrent call. add a test that attempts concurrent invocation and confirms the second call is rejected.

  2. inconsistent pendingMenuQuotaRefresh handling: reset mode at lib/codex-manager.ts:4006-4008 awaits pendingMenuQuotaRefresh before calling resetLocalState(), but fresh-mode deleteAll at lib/codex-manager.ts:3970-3993 does not await it before calling deleteSavedAccounts(). either both should await (safer) or both should skip (if quota refresh is not critical for account deletion). align these paths.

index.ts (5)

104-109: lgtm — imports align with new destructive-action api.

imports DESTRUCTIVE_ACTION_COPY, deleteAccountAtIndex, deleteSavedAccounts, and resetLocalState from lib/destructive-actions.js:1. matches the new api surface documented in the pr summary.


3108-3124: lgtm — manage path now uses deleteAccountAtIndex with proper state sync.

the call to deleteAccountAtIndex at index.ts:3108 returns { storage, flagged, removedAccount, removedFlaggedCount }. applying via Object.assign at lines 3113-3114 updates the in-memory working copies correctly, and the log message now surfaces the removed flagged count per pr objectives.


3150-3161: lgtm — fresh path uses deleteSavedAccounts with completion messaging.

deleteSavedAccounts() at index.ts:3153 returns { accountsCleared }, and the ternary at lines 3157-3159 correctly chooses the standard copy or the warning message.


3166-3180: lgtm — reset handler uses resetLocalState and surfaces partial-failure warnings.

the new menuResult.mode === "reset" branch at index.ts:3166 correctly calls resetLocalState() from lib/destructive-actions.js:1 and checks all three flags (accountsCleared, flaggedCleared, quotaCacheCleared) before declaring full success.

the missing destructiveActionInFlight guard and regression tests for concurrent dispatch were already flagged in a prior review — see past comment on lines 3166-3180.


3943-3967: codex-remove tool still mutates storage inline — same gap flagged in prior review.

the codex-remove tool at index.ts:3943 splices the account array directly and manually adjusts indices at lines 3949-3966. this bypasses the rollback, in-flight guard, and flagged-sync guarantees provided by deleteAccountAtIndex in lib/destructive-actions.ts:1.

this is the same issue noted in the past review comment on lines 3108-3123: the advanced tool path needs migration to deleteAccountAtIndex.

Copy link
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 (1)
test/codex-manager-cli.test.ts (1)

3391-3465: ⚠️ Potential issue | 🟠 Major

add the windows delete-failure regression for manage mode.

test/codex-manager-cli.test.ts:3391-3551 only covers successful deletes. it still never drives deleteAccountAtIndexMock through an EPERM or EBUSY rejection, so the manage-path crash this pr calls out can regress without a failing test. add one deterministic case that rejects the helper and asserts the session stays alive and surfaces the user-facing warning instead of bubbling the error.

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.

Also applies to: 3467-3551

♻️ Duplicate comments (2)
test/storage.test.ts (1)

3496-3506: ⚠️ Potential issue | 🟡 Minor

stop keying the first transient failure off spy call count.

test/storage.test.ts:3496-3506 still uses unlinkSpy.mock.calls.length === 1 inside the mock body. any extra unlink added to clearQuotaCache() will break this regression for the wrong reason. use the local boolean/counter pattern already used by the other retry tests in this file.

diff
 				const realUnlink = fs.unlink.bind(fs);
+				let failedOnce = false;
 				const unlinkSpy = vi
 					.spyOn(fs, "unlink")
 					.mockImplementation(async (target) => {
-						if (target === quotaPath && unlinkSpy.mock.calls.length === 1) {
+						if (target === quotaPath && !failedOnce) {
+							failedOnce = true;
 							const err = new Error("locked") as NodeJS.ErrnoException;
 							err.code = code;
 							throw err;
 						}
 						return realUnlink(target);
 					});

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/storage.test.ts` around lines 3496 - 3506, The mock for fs.unlink should
not rely on unlinkSpy.mock.calls.length to trigger the transient failure;
replace that check with a local boolean or counter captured in the test (e.g.,
let firstAttempt = true) so the mock for unlink (used in the clearQuotaCache()
test, referencing quotaPath and unlinkSpy) throws the locked error only on the
intended first invocation and then delegates to realUnlink thereafter; update
the mockImplementation to use this local flag/counter pattern (as used in the
other retry tests) instead of reading unlinkSpy.mock.calls.length.
test/codex-manager-cli.test.ts (1)

3722-3752: ⚠️ Potential issue | 🟠 Major

cover the partial-cleanup warning branches, not just the completed copy.

test/codex-manager-cli.test.ts:3722-3752 and test/codex-manager-cli.test.ts:3847-3877 only assert the all-success messages. the windows partial-cleanup branches in lib/codex-manager.ts:3971 and lib/codex-manager.ts:4000 still have no regression, so a helper result with a failed cleanup flag can slip through without failing this suite. add one case per command that returns a partial cleanup result and asserts the warning text.

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.

Also applies to: 3847-3877

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

In `@test/codex-manager-cli.test.ts` around lines 3722 - 3752, Add deterministic
Vitest cases that exercise the partial-cleanup branches by having the
deleteSavedAccountsMock (or whichever helper returns cleanup results) resolve
with an object indicating partialCleanup: true (or the existing failed-cleanup
flag shape used by lib/codex-manager.ts) for both commands exercised by
runCodexMultiAuthCli (the "auth login" case at 3722-3752 and the other case at
3847-3877). Specifically, add one test per command that sets loadAccountsMock
and promptLoginModeMock as in the existing tests but make
deleteSavedAccountsMock return the partial-cleanup result, then assert the CLI
exit code and that console.log was called with the exact warning message emitted
by the partial-cleanup branches in lib/codex-manager.ts (the warning text around
the code handling failed cleanup at ~3971 and ~4000); keep tests deterministic
by using vi.spyOn/vi.mockResolvedValue and restore spies.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/codex-manager.ts`:
- Around line 3820-3843: The delete-account flow can throw and bubble out of
runActionPanel causing the session to exit; wrap the await deleteAccountAtIndex
call (and surrounding post-delete logic that accesses deleted.*) in a try/catch
so failures (e.g. EPERM/EAGAIN, sync failures) are handled locally: set
destructiveActionInFlight back to false in finally (keep that), log a
non-sensitive error via console.error (avoid leaking tokens/emails), and return
to the menu instead of rethrowing. Ensure autoSyncActiveAccountToCodex is only
awaited if deleteAccountAtIndex resolved, and add a vitest regression in
test/codex-manager-cli.test.ts that mocks deleteAccountAtIndex to reject and
asserts the menu/session continues (mock EBUSY/EPERM style errors); reference
destructiveActionInFlight, deleteAccountAtIndex, autoSyncActiveAccountToCodex,
and runActionPanel when updating code and tests.

In `@test/codex-manager-cli.test.ts`:
- Around line 3553-3665: Add a deterministic mixed-action test that proves the
destructive-action guard is global by starting one run that triggers
resetLocalStateMock and holds it open (use a deferred) while a second concurrent
run attempts a delete (use deleteAccountAtIndexMock or deleteSavedAccountsMock);
mock promptLoginModeMock to return the appropriate modes for each run, resolve a
deferred when the second menu is attempted, assert the skip message ("Another
destructive action is already running. Wait for it to finish.") was logged and
that the delete mock was not called while reset was in progress, then release
the reset deferred and assert both runs exit normally; reference and reuse the
existing helpers and symbols from the file like runCodexMultiAuthCli,
resetLocalStateMock, deleteAccountAtIndexMock/deleteSavedAccountsMock,
promptLoginModeMock and the skip message to locate where to add this test.

In `@test/storage.test.ts`:
- Around line 2563-2591: Add explicit non-alias checks to ensure the canonical
storage file and the two legacy paths are distinct before seeding the fixture:
after computing canonicalPath, legacyProjectPath and legacyWorktreePath (in the
test that calls prepareWorktreeFixture, setStoragePath, getStoragePath,
getConfigDir and getProjectStorageKey) assert that canonicalPath is not equal to
legacyProjectPath and not equal to legacyWorktreePath (e.g.
expect(canonicalPath).not.toBe(legacyProjectPath) and
expect(canonicalPath).not.toBe(legacyWorktreePath)) immediately before creating
directories and writing the files, so the test fails if normalization regresses
and the same file would be written multiple times.

---

Duplicate comments:
In `@test/codex-manager-cli.test.ts`:
- Around line 3722-3752: Add deterministic Vitest cases that exercise the
partial-cleanup branches by having the deleteSavedAccountsMock (or whichever
helper returns cleanup results) resolve with an object indicating
partialCleanup: true (or the existing failed-cleanup flag shape used by
lib/codex-manager.ts) for both commands exercised by runCodexMultiAuthCli (the
"auth login" case at 3722-3752 and the other case at 3847-3877). Specifically,
add one test per command that sets loadAccountsMock and promptLoginModeMock as
in the existing tests but make deleteSavedAccountsMock return the
partial-cleanup result, then assert the CLI exit code and that console.log was
called with the exact warning message emitted by the partial-cleanup branches in
lib/codex-manager.ts (the warning text around the code handling failed cleanup
at ~3971 and ~4000); keep tests deterministic by using
vi.spyOn/vi.mockResolvedValue and restore spies.

In `@test/storage.test.ts`:
- Around line 3496-3506: The mock for fs.unlink should not rely on
unlinkSpy.mock.calls.length to trigger the transient failure; replace that check
with a local boolean or counter captured in the test (e.g., let firstAttempt =
true) so the mock for unlink (used in the clearQuotaCache() test, referencing
quotaPath and unlinkSpy) throws the locked error only on the intended first
invocation and then delegates to realUnlink thereafter; update the
mockImplementation to use this local flag/counter pattern (as used in the other
retry tests) instead of reading unlinkSpy.mock.calls.length.
🪄 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: ba40e3e1-e49b-4091-a88e-ada914e7e53d

📥 Commits

Reviewing files that changed from the base of the PR and between 5eb23bf and 983eb18.

📒 Files selected for processing (7)
  • lib/codex-manager.ts
  • lib/quota-cache.ts
  • lib/storage.ts
  • test/codex-manager-cli.test.ts
  • test/quota-cache.test.ts
  • test/storage-flagged.test.ts
  • test/storage.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/quota-cache.ts
  • lib/storage.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/quota-cache.test.ts
  • test/storage-flagged.test.ts
  • test/codex-manager-cli.test.ts
  • test/storage.test.ts
🔇 Additional comments (4)
test/storage-flagged.test.ts (1)

235-322: good retry matrix coverage for windows fs races.

test/storage-flagged.test.ts:235 and test/storage-flagged.test.ts:279 now cover EPERM/EBUSY/EAGAIN on both retry-success and retry-exhaustion paths with stable assertions.

lib/storage.ts (1)

182-200: retry + boolean cleanup contract looks solid.

lib/storage.ts:line 182, lib/storage.ts:line 2005, and lib/storage.ts:line 2280 consistently apply retryable unlink behavior and return explicit success flags for destructive cleanup paths.

Also applies to: 2005-2050, 2280-2331

lib/quota-cache.ts (1)

33-48: good hardening on retry and log sanitization.

lib/quota-cache.ts:line 34 and lib/quota-cache.ts:line 286 now align transient fs handling with windows lock behavior while keeping warning output path-safe.

Also applies to: 286-310

test/quota-cache.test.ts (1)

83-224: nice regression expansion for windows fs behavior.

test/quota-cache.test.ts:line 108, test/quota-cache.test.ts:line 142, and test/quota-cache.test.ts:line 186 add deterministic vitest coverage for transient retry and path-redacted warnings, including EAGAIN.

Also applies to: 271-311

Copy link
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

♻️ Duplicate comments (4)
lib/codex-manager.ts (2)

3842-3845: ⚠️ Potential issue | 🟠 Major

don't print raw delete exceptions to the menu.

lib/codex-manager.ts:3842-3845 interpolates error.message directly into output. that bypasses the redaction work in this pr: any unsanitized storage/quota error will leak local paths here. log a generic or code-only failure instead, and add a vitest regression in test/codex-manager-cli.test.ts that injects a path-bearing delete error and asserts the path never appears.

proposed fix
 				} catch (error) {
-					console.log(
-						`Failed to delete account: ${error instanceof Error ? error.message : String(error)}`,
-					);
+					const code = (error as NodeJS.ErrnoException | undefined)?.code;
+					console.error(
+						code
+							? `Failed to delete account (${code}). Please retry.`
+							: "Failed to delete account. Please retry.",
+					);
 				}

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 AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/codex-manager.ts` around lines 3842 - 3845, The catch block that
currently logs the raw delete error in lib/codex-manager.ts (the catch handling
account deletion around the deleteAccount/deleteUser flow) must stop
interpolating error.message into console output; replace the raw output with a
generic failure message or an error code/token (e.g., "Failed to delete account:
ERROR_DELETE_FAILED") and log the full error only to a secure logger that
redacts paths, not to the menu/console. Update the delete-account catch handling
in the function where the current console.log(`Failed to delete account:
${...}`) appears to emit a non-sensitive message, and add a vitest regression in
test/codex-manager-cli.test.ts that injects a thrown Error containing a
filesystem path (simulate delete throwing EBUSY/path) and asserts the rendered
menu/console output does NOT contain that path while still signaling failure.

3832-3840: ⚠️ Potential issue | 🟠 Major

don't report delete success when codex resync failed.

lib/codex-manager.ts:3832-3840 ignores the boolean returned by autoSyncActiveAccountToCodex(). when the deleted row was current and the follow-up sync fails — including the empty-pool early return at lib/codex-manager.ts:4191-4194 — this branch still prints a success message and leaves codex cli pointed at removed credentials. handle the false result explicitly here, and add a vitest regression in test/codex-manager-cli.test.ts for deleting the final active account.

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 AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/codex-manager.ts` around lines 3832 - 3840, The code prints a success
message regardless of whether autoSyncActiveAccountToCodex() succeeded; change
the post-delete flow in the block handling deleted (references: deleted,
deleted.removedAccount, deleted.removedFlaggedCount) to await
autoSyncActiveAccountToCodex(), check its boolean result, and only log the
"Deleted ..." success via console.log when it returns true; if it returns false,
log a non-sensitive error message (do not include tokens/emails) and ensure the
Codex CLI is not assumed to point at removed credentials. Additionally add a
vitest regression in test/codex-manager-cli.test.ts that deletes the final
active account and asserts that the sync failure prevents a success message and
leaves active account selection unchanged.
test/storage.test.ts (1)

3500-3510: ⚠️ Potential issue | 🟡 Minor

make the transient quota-cache retry mock deterministic.

test/storage.test.ts:3500-3510 uses unlinkSpy.mock.calls.length === 1 to decide when to inject the first failure. that couples the regression to the current internal call ordering in clearQuotaCache(). the same file already switched the storage-clear tests to a stable failedOnce flag at test/storage.test.ts:1803-1815; mirror that pattern here so this test does not break if the implementation adds another unlink before the quota file.

proposed fix
 				const realUnlink = fs.unlink.bind(fs);
+				let failedOnce = false;
 				const unlinkSpy = vi
 					.spyOn(fs, "unlink")
 					.mockImplementation(async (target) => {
-						if (target === quotaPath && unlinkSpy.mock.calls.length === 1) {
+						if (target === quotaPath && !failedOnce) {
+							failedOnce = true;
 							const err = new Error("locked") as NodeJS.ErrnoException;
 							err.code = code;
 							throw err;
 						}

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. reject changes that mock real secrets or skip assertions.

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

In `@test/storage.test.ts` around lines 3500 - 3510, The test injection that
throws on the first unlink call is brittle because it checks
unlinkSpy.mock.calls.length; change this to a deterministic closure flag like
failedOnce: declare let failedOnce = false before creating unlinkSpy, then in
the mock implementation use if (target === quotaPath && !failedOnce) {
failedOnce = true; const err = new Error("locked") as NodeJS.ErrnoException;
err.code = code; throw err; } else return realUnlink(target); — update the mock
that references unlinkSpy, fs.unlink, quotaPath and ensure this mirrors the
earlier stable pattern used around clearQuotaCache() tests.
test/codex-manager-cli.test.ts (1)

3772-3801: ⚠️ Potential issue | 🟠 Major

the windows partial-cleanup warning path is still untested.

test/codex-manager-cli.test.ts:3772-3801 and test/codex-manager-cli.test.ts:3897-3927 only pin the completed-copy path. they never make deleteSavedAccountsMock or resetLocalStateMock report a cleanup miss, so the ebusy/eperm warning branch can still regress green. add one deterministic delete-saved-accounts case and one reset case that assert the warning copy instead of the success copy.

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. reject changes that mock real secrets or skip assertions.

Also applies to: 3897-3927

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

In `@test/codex-manager-cli.test.ts` around lines 3772 - 3801, Add two
deterministic test cases in test/codex-manager-cli.test.ts that exercise the
"partial-cleanup" warning branch by making the mocked cleanup helpers indicate a
cleanup miss: one where deleteSavedAccountsMock resolves to a value that signals
failure (e.g., false or a non-success result) while resetLocalStateMock remains
uncalled, and one where resetLocalStateMock resolves to indicate failure while
deleteSavedAccountsMock succeeds. In each case, call
runCodexMultiAuthCli(["auth", "login"]) with promptLoginModeMock returning the
same flows used in the existing tests, then assert exitCode === 0 and that
console.log was called with the windows partial-cleanup warning message instead
of the success message; ensure you use vitest mocks (vi.spyOn/
mockResolvedValueOnce) so tests remain deterministic and do not touch real
secrets.
🤖 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 3113-3116: The Object.assign calls that copy deleted.storage into
workingStorage and deleted.flagged into flaggedStorage (followed by
invalidateAccountManagerCache()) are dead code because deleteAccountAtIndex
performs a two-phase write and the loop immediately continues and reloads
workingStorage/flaggedStorage; either remove those Object.assign lines and the
subsequent invalidateAccountManagerCache() call, or keep them but add a clear
comment in the block (referencing deleted, workingStorage, flaggedStorage,
invalidateAccountManagerCache, deleteAccountAtIndex) explaining that these
assignments are intentionally retained to support a future optimization to skip
the reload on the next iteration; implement one of those two options.

In `@test/cli.test.ts`:
- Around line 227-273: The test mutates stdin.isTTY/stdout.isTTY by replacing
only boolean values which can leave altered property descriptors behind; change
the setup/teardown in the "returns reset for TTY reset-all confirmation" case
(and the other similar blocks) to save the full property descriptors via
Object.getOwnPropertyDescriptor(stdin, "isTTY") and
Object.getOwnPropertyDescriptor(stdout, "isTTY") before mocking, then restore
those saved descriptors with Object.defineProperty in the finally block (instead
of writing back a simple value). Update the block that manipulates isTTY (and
corresponding blocks around promptLoginMode, mockRl.question, and
showAuthMenuMock) to use descriptor snapshot/restore so inherited or
non-writable descriptor shapes are preserved.

In `@test/codex-manager-cli.test.ts`:
- Around line 3553-3600: The test currently queues a second prompt response but
doesn't verify it was consumed; add an assertion that the menu prompt was
invoked again to prove the loop continued after delete failure (e.g., assert
promptLoginModeMock was called twice or that the second "cancel" branch was
taken). Update the test near the end (after the existing expectations) to
include something like expect(promptLoginModeMock).toHaveBeenCalledTimes(2) or
an assertion that the cancel result was used, referencing the
promptLoginModeMock and runCodexMultiAuthCli symbols.

---

Duplicate comments:
In `@lib/codex-manager.ts`:
- Around line 3842-3845: The catch block that currently logs the raw delete
error in lib/codex-manager.ts (the catch handling account deletion around the
deleteAccount/deleteUser flow) must stop interpolating error.message into
console output; replace the raw output with a generic failure message or an
error code/token (e.g., "Failed to delete account: ERROR_DELETE_FAILED") and log
the full error only to a secure logger that redacts paths, not to the
menu/console. Update the delete-account catch handling in the function where the
current console.log(`Failed to delete account: ${...}`) appears to emit a
non-sensitive message, and add a vitest regression in
test/codex-manager-cli.test.ts that injects a thrown Error containing a
filesystem path (simulate delete throwing EBUSY/path) and asserts the rendered
menu/console output does NOT contain that path while still signaling failure.
- Around line 3832-3840: The code prints a success message regardless of whether
autoSyncActiveAccountToCodex() succeeded; change the post-delete flow in the
block handling deleted (references: deleted, deleted.removedAccount,
deleted.removedFlaggedCount) to await autoSyncActiveAccountToCodex(), check its
boolean result, and only log the "Deleted ..." success via console.log when it
returns true; if it returns false, log a non-sensitive error message (do not
include tokens/emails) and ensure the Codex CLI is not assumed to point at
removed credentials. Additionally add a vitest regression in
test/codex-manager-cli.test.ts that deletes the final active account and asserts
that the sync failure prevents a success message and leaves active account
selection unchanged.

In `@test/codex-manager-cli.test.ts`:
- Around line 3772-3801: Add two deterministic test cases in
test/codex-manager-cli.test.ts that exercise the "partial-cleanup" warning
branch by making the mocked cleanup helpers indicate a cleanup miss: one where
deleteSavedAccountsMock resolves to a value that signals failure (e.g., false or
a non-success result) while resetLocalStateMock remains uncalled, and one where
resetLocalStateMock resolves to indicate failure while deleteSavedAccountsMock
succeeds. In each case, call runCodexMultiAuthCli(["auth", "login"]) with
promptLoginModeMock returning the same flows used in the existing tests, then
assert exitCode === 0 and that console.log was called with the windows
partial-cleanup warning message instead of the success message; ensure you use
vitest mocks (vi.spyOn/ mockResolvedValueOnce) so tests remain deterministic and
do not touch real secrets.

In `@test/storage.test.ts`:
- Around line 3500-3510: The test injection that throws on the first unlink call
is brittle because it checks unlinkSpy.mock.calls.length; change this to a
deterministic closure flag like failedOnce: declare let failedOnce = false
before creating unlinkSpy, then in the mock implementation use if (target ===
quotaPath && !failedOnce) { failedOnce = true; const err = new Error("locked")
as NodeJS.ErrnoException; err.code = code; throw err; } else return
realUnlink(target); — update the mock that references unlinkSpy, fs.unlink,
quotaPath and ensure this mirrors the earlier stable pattern used around
clearQuotaCache() tests.
🪄 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: b3c9d6fa-a653-422e-b4ec-6344bddfcb24

📥 Commits

Reviewing files that changed from the base of the PR and between 983eb18 and fdc7f20.

📒 Files selected for processing (6)
  • index.ts
  • lib/cli.ts
  • lib/codex-manager.ts
  • test/cli.test.ts
  • test/codex-manager-cli.test.ts
  • test/storage.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/codex-manager.ts
  • lib/cli.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/cli.test.ts
  • test/codex-manager-cli.test.ts
  • test/storage.test.ts
🔇 Additional comments (3)
index.ts (3)

104-109: imports from lib/destructive-actions.ts look correct.

these align with the new destructive-action api surface. deleteAccountAtIndex, deleteSavedAccounts, resetLocalState, and DESTRUCTIVE_ACTION_COPY are all documented in the enriched summary and match the interface definitions at lib/destructive-actions.ts:88-99,193-215.


3172-3186: index.ts reset/fresh dispatch still lacks concurrent protection.

previous review flagged that lines 3164-3178 (now 3172-3186) execute destructive actions without the destructiveActionInFlight guard enforced by lib/codex-manager.ts:3960,3984. the fresh path at lines 3156-3169 has the same gap.

while this runs inside an interactive menu loop (low concurrency risk in practice), the index-level entry points diverge from the guarded lib-level equivalents, leaving a consistency gap vs what test/destructive-actions.test.ts validates.

consider:

  1. importing and reusing the module-level guard from lib/codex-manager.ts, or
  2. adding a local guard in index.ts that serializes/rejects concurrent reset/fresh/delete calls.

3156-3169: fresh path integration looks correct.

deleteSavedAccounts() returns DestructiveActionResult per lib/destructive-actions.ts:193-199. the conditional message handling for result.accountsCleared properly surfaces partial failures. cache invalidation follows at line 3160.

(concurrent protection gap covered in separate comment.)

Comment on lines +3113 to +3116
if (deleted) {
Object.assign(workingStorage, deleted.storage);
Object.assign(flaggedStorage, deleted.flagged);
invalidateAccountManagerCache();
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Object.assign calls are dead code in current control flow.

deleteAccountAtIndex persists changes internally (per lib/destructive-actions.ts two-phase write). after this block, line 3131 hits continue, jumping back to the top of the while(true) loop at line 3029 where workingStorage and flaggedStorage are reloaded from disk anyway. these assignments update local vars that are immediately discarded.

either remove the Object.assign calls or, if keeping them for future use (e.g., skipping reload on the next iteration), add a comment explaining why.

🧹 suggested cleanup
 if (deleted) {
-  Object.assign(workingStorage, deleted.storage);
-  Object.assign(flaggedStorage, deleted.flagged);
   invalidateAccountManagerCache();
   const label =
     deleted.removedAccount.email?.trim() ||
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@index.ts` around lines 3113 - 3116, The Object.assign calls that copy
deleted.storage into workingStorage and deleted.flagged into flaggedStorage
(followed by invalidateAccountManagerCache()) are dead code because
deleteAccountAtIndex performs a two-phase write and the loop immediately
continues and reloads workingStorage/flaggedStorage; either remove those
Object.assign lines and the subsequent invalidateAccountManagerCache() call, or
keep them but add a clear comment in the block (referencing deleted,
workingStorage, flaggedStorage, invalidateAccountManagerCache,
deleteAccountAtIndex) explaining that these assignments are intentionally
retained to support a future optimization to skip the reload on the next
iteration; implement one of those two options.

Comment on lines +227 to +273
it("returns reset for TTY reset-all confirmation", async () => {
const { stdin, stdout } = await import("node:process");
const origInputTTY = stdin.isTTY;
const origOutputTTY = stdout.isTTY;
const showAuthMenuMock = vi.fn().mockResolvedValue({ type: "reset-all" });

Object.defineProperty(stdin, "isTTY", {
value: true,
writable: true,
configurable: true,
});
Object.defineProperty(stdout, "isTTY", {
value: true,
writable: true,
configurable: true,
});

try {
vi.resetModules();
vi.doMock("../lib/ui/auth-menu.js", async () => {
const actual = await vi.importActual("../lib/ui/auth-menu.js");
return {
...(actual as Record<string, unknown>),
isTTY: vi.fn(() => true),
showAuthMenu: showAuthMenuMock,
};
});
mockRl.question.mockResolvedValueOnce("RESET");

const { promptLoginMode } = await import("../lib/cli.js");
const result = await promptLoginMode([{ index: 0 }]);

expect(result).toEqual({ mode: "reset" });
expect(showAuthMenuMock).toHaveBeenCalledTimes(1);
} finally {
vi.doUnmock("../lib/ui/auth-menu.js");
Object.defineProperty(stdin, "isTTY", {
value: origInputTTY,
writable: true,
configurable: true,
});
Object.defineProperty(stdout, "isTTY", {
value: origOutputTTY,
writable: true,
configurable: true,
});
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

restore the original tty descriptors.

test/cli.test.ts:227-273, test/cli.test.ts:276-329, test/cli.test.ts:498-527, and test/cli.test.ts:715-766 only stash the boolean isTTY values and then write back own data properties. on node runtimes where isTTY is inherited or has a different descriptor shape, that leaves mutated globals behind and can bleed into later cases. please snapshot and restore the full descriptor here, like test/codex-manager-cli.test.ts:219-250 already does.

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. reject changes that mock real secrets or skip assertions.

Also applies to: 276-329, 498-527, 715-766

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

In `@test/cli.test.ts` around lines 227 - 273, The test mutates
stdin.isTTY/stdout.isTTY by replacing only boolean values which can leave
altered property descriptors behind; change the setup/teardown in the "returns
reset for TTY reset-all confirmation" case (and the other similar blocks) to
save the full property descriptors via Object.getOwnPropertyDescriptor(stdin,
"isTTY") and Object.getOwnPropertyDescriptor(stdout, "isTTY") before mocking,
then restore those saved descriptors with Object.defineProperty in the finally
block (instead of writing back a simple value). Update the block that
manipulates isTTY (and corresponding blocks around promptLoginMode,
mockRl.question, and showAuthMenuMock) to use descriptor snapshot/restore so
inherited or non-writable descriptor shapes are preserved.

@ndycode ndycode changed the base branch from main to dev March 18, 2026 06:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants