Skip to content

feat(auth): add backup restore manager#105

Open
ndycode wants to merge 11 commits intodevfrom
fresh/02-backup-restore-manager
Open

feat(auth): add backup restore manager#105
ndycode wants to merge 11 commits intodevfrom
fresh/02-backup-restore-manager

Conversation

@ndycode
Copy link
Owner

@ndycode ndycode commented Mar 16, 2026

Summary

  • Reopens the preserved backup-restore-manager slice from archived PR #76
  • Stacked PR on fresh/01-reset-safety

What Changed

  • Adds the direct and interactive backup restore manager flow, including backup assessment, restore selection, and the related command/reference docs.
  • Keeps the scope limited to restore-manager behavior and its matching CLI/storage coverage.

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 f48ee06

Additional Notes

  • Preserved branch validation from the fresh-restack run passed npm run lint, npm run typecheck, npm run build, and targeted Vitest coverage for the restore-manager slice.

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 reopens the backup-restore-manager slice from archived pr #76, adding a codex auth restore-backup direct command and a "Restore From Backup" entry under a new "Recovery" section in the login menu. it also refactors runAuthLogin to remove the empty-account gate so the menu is always shown (enabling restore when no accounts exist yet), and upgrades importAccounts with a changed field and a no-op skip path that avoids redundant writes when backup data is already identical to current storage.

key changes and observations:

  • lib/storage.ts — adds listNamedBackups, assessNamedBackupRestore, restoreAssessedNamedBackup, and supporting helpers. path containment is enforced via lstat + realpath with BackupContainmentError for hard violations and BackupPathValidationTransientError (+ retry) for transient windows AV/file-lock cases. NAMED_BACKUP_LIST_CONCURRENCY=8 and NAMED_BACKUP_ASSESS_CONCURRENCY=4 bound concurrent i/o. importAccounts is refactored to pre-deduplicate both the existing and incoming account lists before merging — this fixes a pre-existing negative import count bug when the current storage has duplicate rows.
  • lib/codex-manager.tsrunBackupRestoreManager correctly drains pendingMenuQuotaRefresh before starting the restore flow. it does a fresh re-assessment (no cache) before the confirm dialog to reflect any concurrent storage changes. a two-phase Promise.allSettled batch assessment with containment error propagation is implemented correctly.
  • test coverage — ~1400 new lines in codex-manager-cli.test.ts cover all major paths: happy path from login menu and direct command, TTY guard, no-op, metadata-refresh wording, concurrent assessment limit, quota-drain ordering, and containment error propagation. storage.test.ts adds real-fs integration tests for the no-op skip, metadata-refresh persist, and the existsSync false-negative regression.
  • minor issues found: log.info("Imported accounts", ...) fires even on the no-op (changed === false) path; __testOnly.namedBackupContainmentFs is exported unconditionally into the production module surface; the comment on haveEquivalentAccountRows slightly mischaracterises the dedup ordering guarantee; the pre-confirm re-assessment reads the directory and file fresh even though a warm candidateCache could avoid the extra file read on windows.

Confidence Score: 3/5

  • safe to merge after addressing the no-op log.info and reviewing the __testOnly export policy; no blocking correctness bugs found.
  • the core logic is sound — path containment, retry handling, concurrent i/o limits, and the importAccounts refactor are all well-tested. the identified issues are quality/hygiene concerns (misleading log, exposed internal shim, extra i/o on windows) rather than correctness bugs. the test suite is thorough. score reflects the complexity and volume of new i/o code in storage.ts that deserves careful review.
  • lib/storage.ts (log.info on no-op path, __testOnly export); lib/codex-manager.ts (pre-confirm re-assessment cache efficiency on windows)

Important Files Changed

Filename Overview
lib/storage.ts largest file in the PR — adds listNamedBackups, assessNamedBackupRestore, restoreAssessedNamedBackup, importAccounts refactor (adds changed field, fixes negative import count bug, adds no-op skip). key issues: log.info fires on no-op path, __testOnly exports internal containment shim into production build.
lib/codex-manager.ts adds runBackupRestoreManager, restore-backup CLI command, flattens runAuthLogin loop so the menu shows even with no accounts. quota-drain await is correctly placed before restore. re-assessment before confirm is fresh (no cache) which may cause extra windows AV round-trips.
lib/cli.ts adds restore-backup to LoginMode union and the promptLoginMode switch — minimal, correct change.
lib/ui/auth-menu.ts adds restore-backup menu item under a new "Recovery" heading and restore-backup to the focus-key switch. clean and consistent with existing menu patterns.
lib/ui/copy.ts adds copy strings for the restore backup flow (confirm, success, metadata-refresh success messages). all are well-parameterised functions, no issues.
test/codex-manager-cli.test.ts adds ~1400 lines of new tests covering all major restore-manager flows (happy path, no-op, TTY guard, concurrent assessment limit, reassessment before confirm, containment error propagation). coverage is comprehensive. quota-drain ordering test is particularly valuable for concurrency correctness.
test/storage.test.ts adds unit tests for importAccounts (no-op skip, metadata-refresh persist, dedup incoming rows, MAX_ACCOUNTS boundary), listNamedBackups (create/list, chunk boundary), resolveNamedBackupRestorePath, assertNamedBackupRestorePath, and the existsSync false-negative regression. good integration-level coverage using real fs in a temp dir.
test/cli.test.ts adds test confirming restore-backup aliases are correctly rejected from the fallback readline flow. no issues.
docs/reference/storage-paths.md documents named backup paths, restore workflow, and direct entrypoint. accurate and aligned with the implementation.
docs/reference/commands.md adds codex auth restore-backup to both the command table and the repair/recovery bash block. correct.
docs/reference/public-api.md documents the additive ImportAccountsResult.changed field and backward-compat note. accurate.

Sequence Diagram

sequenceDiagram
    participant User
    participant CLI as runCodexMultiAuthCli
    participant Manager as runBackupRestoreManager
    participant Storage as storage.ts
    participant FS as filesystem

    User->>CLI: codex auth restore-backup (or login menu)
    CLI->>CLI: setStoragePath(null)
    CLI->>Manager: runBackupRestoreManager(displaySettings)
    Manager->>Manager: isNonInteractiveMode / TTY check
    Manager->>Storage: listNamedBackups({ candidateCache })
    Storage->>FS: readdir(backupRoot)
    Storage->>FS: retryTransientFilesystemOperation(readFile) × N (chunked, NAMED_BACKUP_LIST_CONCURRENCY=8)
    Storage-->>Manager: NamedBackupMetadata[]

    Manager->>Storage: assessNamedBackupRestore(name, { currentStorage, candidateCache }) × M (chunked, NAMED_BACKUP_ASSESS_CONCURRENCY=4)
    Note over Manager,Storage: cache hit from listNamedBackups — avoids re-read
    Storage-->>Manager: BackupRestoreAssessment[]

    Manager->>User: select(backupItems) — menu with eligibility hints
    User-->>Manager: { type: "restore", assessment }

    Manager->>Storage: assessNamedBackupRestore(name, { currentStorage: fresh loadAccounts() })
    Note over Manager,Storage: fresh re-assessment — no cache — reflects concurrent changes
    Storage->>FS: readdir + readFile (fresh)
    Storage-->>Manager: latestAssessment

    Manager->>User: confirm(mergedAccountCount diff message)
    User-->>Manager: true

    Manager->>Storage: restoreAssessedNamedBackup(latestAssessment)
    Storage->>Storage: assertNamedBackupRestorePath(path, backupRoot)
    Note over Storage: containment check via lstat + realpath
    Storage->>Storage: importAccounts(resolvedPath)
    Storage->>FS: retryTransientFilesystemOperation(readFile)
    Storage->>Storage: withAccountStorageTransaction → haveEquivalentAccountRows → persist if changed
    Storage-->>Manager: ImportAccountsResult { imported, skipped, total, changed }

    Manager->>CLI: autoSyncActiveAccountToCodex()
    Manager-->>User: success message
Loading

Comments Outside Diff (14)

  1. lib/ui/copy.ts, line 1739-1741 (link)

    "u" in fallback prompt is never handled — causes an infinite re-prompt loop

    the fallback readline handler in lib/cli.ts (around line 188–239) has explicit mappings for "a", "c", "b", "x", "s", "d", "g", "f", "r", and "q", but there is no branch for "u". a user in fallback mode (non-TUI terminal) who reads the prompt, types "u", and presses enter will receive:

    Use one of: a, c, b, x, s, d, g, u, f, r, q.
    

    …which lists "u" as valid, so they try it again — infinite loop with no escape other than Ctrl-C or guessing "q".

    there are two ways to fix this:

    option A — remove "u" from both the prompt string and the invalidModePrompt in the fallback copy (restore-backup requires a TUI and is exposed as a dedicated sub-command codex auth restore-backup):

    option B — add a handler for "u" in the fallback path of promptLoginMode that returns { mode: "restore-backup" } so the caller can surface the "requires interactive TTY" message and continue.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: lib/ui/copy.ts
    Line: 1739-1741
    
    Comment:
    **`"u"` in fallback prompt is never handled — causes an infinite re-prompt loop**
    
    the fallback readline handler in `lib/cli.ts` (around line 188–239) has explicit mappings for `"a"`, `"c"`, `"b"`, `"x"`, `"s"`, `"d"`, `"g"`, `"f"`, `"r"`, and `"q"`, but there is no branch for `"u"`. a user in fallback mode (non-TUI terminal) who reads the prompt, types `"u"`, and presses enter will receive:
    
    ```
    Use one of: a, c, b, x, s, d, g, u, f, r, q.
    ```
    
    …which lists `"u"` as valid, so they try it again — infinite loop with no escape other than `Ctrl-C` or guessing `"q"`.
    
    there are two ways to fix this:
    
    **option A** — remove `"u"` from both the prompt string and the `invalidModePrompt` in the fallback copy (restore-backup requires a TUI and is exposed as a dedicated sub-command `codex auth restore-backup`):
    
    
    
    **option B** — add a handler for `"u"` in the fallback path of `promptLoginMode` that returns `{ mode: "restore-backup" }` so the caller can surface the "requires interactive TTY" message and continue.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Codex

  2. lib/storage.ts, line 1300-1347 (link)

    Sync lstatSync/realpathSync inside resolvePathForNamedBackupContainment blocks the event loop

    namedBackupContainmentFs.lstat and .realpath call lstatSync / realpathSync.native synchronously inside resolvePathForNamedBackupContainment. this function is called from assertNamedBackupRestorePath, which in turn is called concurrently across up to NAMED_BACKUP_LIST_CONCURRENCY = 8 chunks inside listNamedBackups. on windows the sync realpath call can take 50–200 ms under an AV scan — doing this 8 times concurrently blocks the event loop and freezes the entire interactive restore menu until all chunks finish. the __testOnly hook exposes namedBackupContainmentFs so tests can spy on it, which suggests the design intended mockability; using fs.lstat/fs.realpath (async) here and awaiting them in a serial containment check would keep the loop unblocked and be no harder to test.

    this also means no vitest coverage exists for the race between lstat completing and an AV lock being released mid-walk — a scenario specifically called out in the codebase's own comments.

  3. lib/codex-manager.ts, line 640-651 (link)

    Re-assessment passes a stale candidateCache-less call — cache eviction leaks tokens to disk twice

    the re-assessment on line 642 calls assessNamedBackupRestore without candidateCache, so loadBackupCandidate re-reads the file from disk. that is intentional (fresh snapshot). however, assessNamedBackupRestore also calls candidateCache?.delete(backupPath) when a cache is passed to evict after use — without a cache, the candidate is loaded a second time, parsed, and then immediately discarded. on windows with token files that include refresh tokens, this means two separate fs.readFile calls for the same path both hold the file contents in memory simultaneously for the duration of the second JSON parse. if an AV scanner triggers a EBUSY during that second parse (not wrapped in retryTransientFilesystemOperation here), the user sees an unexplained "Restore failed" after successfully passing the menu.

    consider passing an isolated one-shot cache or wrapping this second read in the same retry wrapper used by loadBackupCandidate.

  4. lib/ui/auth-menu.ts, line 1688-1699 (link)

    "Recovery" heading item maps to cancel — keyboard navigation skips it correctly but focus-key collision is silent

    authMenuFocusKey for "cancel" returns the same key as "cancel" in the switch, and the heading item is also given value: { type: "cancel" }. if the user had previously highlighted the "Cancel" item and the menu is re-rendered (e.g. after a background quota refresh), findFocusedItem will match the first cancel-keyed item — which may now be this heading separator — not the original cancel button. this can cause the keyboard cursor to silently jump to the heading on re-render. the heading should carry a distinct non-actionable sentinel like { type: "heading" } or an unused type constant so focus restoration doesn't collide.

  5. lib/storage.ts, line 1093-1112 (link)

    eligibleForRestore is false when imported === 0 but changed === true — metadata-only restores are silently blocked

    when a backup contains accounts that all already exist by ID but with updated tokens/metadata, imported is 0, changed is true, and wouldExceedLimit is false. the current formula is:

    eligibleForRestore: !wouldExceedLimit && changed,

    so a metadata-refresh-only backup is correctly eligible here. however the UI in runBackupRestoreManager checks latestAssessment.eligibleForRestore after the re-assessment, and restoreAssessedNamedBackup throws if !eligibleForRestore — so that path is fine.

    but assessNamedBackupRestore early-returns eligibleForRestore: false when !backup.accountCount || backup.accountCount <= 0. a backup containing one account with updated tokens but no accountCount written in its metadata (e.g. an older export format) would hit this branch and be marked ineligible, discarding a valid metadata-refresh restore silently. the guard should be on candidate.normalized === null, not on backup.accountCount.

  6. lib/storage.ts, line 2232-2238 (link)

    sync containment fs blocks event loop — no retry for transient EPERM/EBUSY

    lstatSync and realpathSync.native run synchronously inside resolvePathForNamedBackupContainment, which is called per-entry during listNamedBackups (up to NAMED_BACKUP_LIST_CONCURRENCY = 8 entries in flight). on windows with an active AV scanner, a single lstatSync call can stall for hundreds of milliseconds, blocking the entire event loop. more importantly, when the AV holds a transient lock, lstatSync throws EPERM, which is caught and re-thrown as BackupPathValidationTransientError — but there is no automatic retry at this level. the async retryTransientFilesystemOperation wrapper exists for all async reads in this file, but the containment check bypasses it entirely. a user hitting a transient lock on any backup in the listing sees "Backup path validation failed. Try again." and must manually retry the whole operation.

    if the sync calls are intentional for reentrancy/simplicity, at minimum document that gap explicitly. if not, the containment check could be made async and wrapped in retryTransientFilesystemOperation like the other disk operations.

  7. lib/codex-manager.ts, line 671-672 (link)

    changed === false branch skips sync but autoSyncActiveAccountToCodex still needed for metadata-refresh path

    when result.changed === false the function returns true without calling autoSyncActiveAccountToCodex. this is correct for a pure no-op (all accounts already identical). however, the branch is reachable only via a race: assessNamedBackupRestore said changed = true (eligible) just before confirmation, but by the time importAccounts committed, another writer had already merged the same data. in that race the codex cli auth state is consistent because the second writer should have synced. if that second writer was a non-interactive path that never syncs (e.g. a raw importAccounts call), the cli state could be stale. this is low-probability, but worth a comment noting the deliberate omission of sync here, so future reviewers don't add it without understanding the changed === false semantics.

  8. lib/storage.ts, line 1227-1233 (link)

    Sync lstatSync/realpathSync block event loop on Windows

    namedBackupContainmentFs uses synchronous FS calls inside async code paths. on Windows under AV scanner or file-indexer pressure, both lstatSync and realpathSync.native can hang for hundreds of milliseconds before throwing EPERM/EBUSY. every call goes through resolvePathForNamedBackupContainment, which is reached once per backup file during listNamedBackups and again during assessNamedBackupRestore. with e.g. 8 backups and NAMED_BACKUP_LIST_CONCURRENCY = 8, all 8 concurrent chunks call these sync functions simultaneously — all on the same event loop thread, potentially adding up to seconds of visible freeze in the interactive restore terminal.

    the existing retry mechanism (retryTransientNamedBackupPathValidationBackupPathValidationTransientError) correctly recovers after the fact, but the initial sync call still blocks. replacing both fields with async equivalents (fs.lstat / fs.realpath via the existing retryTransientFilesystemOperation wrapper) would keep the TOCTOU guarantee while staying non-blocking.

    // current — blocks event loop on both calls
    const namedBackupContainmentFs = {
        lstat(path: string) {
            return lstatSync(path);
        },
        realpath(path: string) {
            return realpathSync.native(path);
        },
    };

    suggest making both fields async-compatible so the __testOnly swap pattern still works but callers can be properly awaited.

  9. lib/storage.ts, line 2177-2178 (link)

    Windows junction points silently break all backup restore

    on windows, mklink /J junction points are reported by lstat().isSymbolicLink() as true — exactly the same as a symlink. this means any user who redirected their ~/.codex/multi-auth/backups dir to external storage via a junction (a very common windows pattern) gets BackupContainmentError for every single restore attempt. the error message "Backup path escapes backup directory" is especially confusing here because the backup path doesn't escape anything — the issue is the root itself is a junction.

    the canonical path check that follows (lines 2180-2192) already handles path traversal via realpath + relative(), so the symlink-root guard is redundant for containment security. if the intent is strictly "no redirected roots", the error message should reflect that explicitly.

    at minimum, the message should distinguish between a root-is-junction rejection and an actual path-traversal rejection so windows users aren't left chasing a red herring:

  10. lib/storage.ts, line 3151-3159 (link)

    restoreAssessedNamedBackup skips containment re-validation before importing

    the function calls importAccounts(assessment.backup.path) directly with no check that the path is still inside the backup directory. importAccounts itself has no containment guard either, so any caller that constructs a BackupRestoreAssessment with an arbitrary backup.path — or one whose canonical location has shifted since assessment (e.g. the backup root was remounted on windows between assess and import) — can read from outside the designated backup directory without triggering a BackupContainmentError.

    the fix is to call assertNamedBackupRestorePath (or resolveNamedBackupRestorePath) on the path before delegating to importAccounts:

    export async function restoreAssessedNamedBackup(
    	assessment: Pick<BackupRestoreAssessment, "backup" | "eligibleForRestore" | "error">,
    ): Promise<ImportAccountsResult> {
    	if (!assessment.eligibleForRestore) {
    		throw new Error(
    			assessment.error ?? "Backup is not eligible for restore.",
    		);
    	}
    	const backupRoot = getNamedBackupRoot(getStoragePath());
    	await retryTransientNamedBackupPathValidation(() =>
    		assertNamedBackupRestorePath(assessment.backup.path, backupRoot),
    	);
    	return importAccounts(assessment.backup.path);
    }

    this is especially important on windows where the backup root can be on a drive that gets remapped or where AV software can briefly make a symlink appear as a regular file during the window between assessNamedBackupRestore and this call.

  11. lib/storage.ts, line 3143-3196 (link)

    silent active-account shift when backup has accounts in a different order

    haveEquivalentAccountRows compares the two arrays index-by-index. deduplicateAccounts([...existingDeduped, ...incomingDeduped]) keeps the last occurrence, so if the backup lists the same accounts in a different order, mergedAccounts will be ordered by the backup's arrangement. changed becomes true, the restore proceeds, accounts are reordered in storage, and activeIndex (which is preserved as-is from existingActiveIndex) now points to a different account silently.

    example:

    • current storage: [A(idx=0, active), B(idx=1)]
    • backup file: [B, A] (same data, different order)
    • merged (last-occurrence wins): [B, A] — order follows the backup
    • activeIndex stays 0 → now points to B instead of A

    the impact is a silent active-account change without any user confirmation that this happened. since the confirmation message only shows the import/skipped counts and not any "account order will change" warning, the user has no signal.

    consider normalising account order before comparison in haveEquivalentAccountRows, or adding a dedicated "order-only change" detection path that either suppresses the restore or warns the user. at minimum, update activeIndex after the merge so it still points to the same account by ID rather than by position.

  12. lib/storage.ts, line 3169-3196 (link)

    importAccounts now skips persist() on duplicate-only imports — silent behavioural break for existing callers

    the new early return:

    if (!changed) {
        return { imported, total, skipped, changed };
    }

    means a call to importAccounts with a file containing only accounts already in storage no longer writes anything to disk. before this PR, importAccounts always called persist, even for identical content.

    callers that relied on the write as a side-effect (e.g. to bump the file's mtime, trigger a filesystem watcher, or force a WAL flush) will silently see no write. the public-api.md documents changed as additive and the existing tests have been updated, but any caller outside this repo that calls importAccounts and expects a write on every call will be broken with no error or warning.

    changed is typed as changed?: boolean (optional) so external callers don't need to model it — but they also can't tell whether the no-write was intentional or a failure. consider either:

    • always writing (persist) even for no-op imports (old behaviour, safe), or
    • documenting the no-write behaviour in a changelog / migration note in docs/upgrade.md (the governance checklist shows that file was not updated for this PR)
  13. lib/storage.ts, line 3209-3215 (link)

    log.info fires on no-op import path

    when changed === false, the transaction callback returns early without calling persist(), but log.info("Imported accounts", ...) still fires with imported: 0, changed: false. log aggregation systems will count this as an import event even when nothing was written. rename or suppress the log for the no-op path:

  14. lib/codex-manager.ts, line 640-645 (link)

    re-assessment re-reads directory and file on every selection

    the pre-confirm re-assessment at this point calls assessNamedBackupRestore without a candidateCache. internally it calls findExistingNamedBackupPath (fresh readdir) and loadBackupCandidate (fresh readFile). that means each user selection triggers at least 3 i/o passes per backup: one during listNamedBackups, one from the cache during batch assessment, and one fresh here.

    on windows with av scanning, a third uncached readFile on the same file could hit an EBUSY and trigger the full retry loop (up to ~1.26 s). the retryTransientNamedBackupPathValidation inside resolveNamedBackupRestorePath wraps the path check but not the file read that happens via loadBackupCandidate. passing a short-lived candidateCache to the re-assessment call would avoid the extra read while still reflecting any mtime change:

    (note: an empty Map is still safe here — getCachedNamedBackupCandidate just misses and falls through to loadBackupCandidate, so behavior is identical; the point is that future callers who pass a warm cache benefit without any code path change.)

Fix All in Codex

Prompt To Fix All With AI
This is a comment left during a code review.
Path: lib/storage.ts
Line: 3209-3215

Comment:
**`log.info` fires on no-op import path**

when `changed === false`, the transaction callback returns early without calling `persist()`, but `log.info("Imported accounts", ...)` still fires with `imported: 0, changed: false`. log aggregation systems will count this as an import event even when nothing was written. rename or suppress the log for the no-op path:

```suggestion
	if (changed !== false) {
		log.info("Imported accounts", {
			path: resolvedPath,
			imported: importedCount,
			skipped: skippedCount,
			total,
			changed,
		});
	} else {
		log.debug("No-op import: all backup accounts already match current storage", {
			path: resolvedPath,
			total,
		});
	}
```

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/storage.ts
Line: 3225-3227

Comment:
**`__testOnly` leaks internal filesystem shim into the public module surface**

`namedBackupContainmentFs` is exposed unconditionally in the production build. any caller can monkey-patch `namedBackupContainmentFs.lstat` / `.realpath` at runtime and bypass containment checks without touching `__testOnly`. consider guarding the export or using vitest's `vi.mock("node:fs")` directly in tests to keep the shim entirely out of the shipped module.

if the shim is truly required for testing, document explicitly that mutating `__testOnly.namedBackupContainmentFs` in non-test code voids containment safety guarantees.

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: 640-645

Comment:
**re-assessment re-reads directory and file on every selection**

the pre-confirm re-assessment at this point calls `assessNamedBackupRestore` without a `candidateCache`. internally it calls `findExistingNamedBackupPath` (fresh `readdir`) and `loadBackupCandidate` (fresh `readFile`). that means each user selection triggers at least 3 i/o passes per backup: one during `listNamedBackups`, one from the cache during batch assessment, and one fresh here.

on windows with av scanning, a third uncached `readFile` on the same file could hit an `EBUSY` and trigger the full retry loop (up to ~1.26 s). the `retryTransientNamedBackupPathValidation` inside `resolveNamedBackupRestorePath` wraps the path check but not the file read that happens via `loadBackupCandidate`. passing a short-lived `candidateCache` to the re-assessment call would avoid the extra read while still reflecting any mtime change:

```suggestion
		latestAssessment = await assessNamedBackupRestore(
			selection.assessment.backup.name,
			{ currentStorage: await loadAccounts(), candidateCache: new Map() },
		);
```

(note: an empty `Map` is still safe here — `getCachedNamedBackupCandidate` just misses and falls through to `loadBackupCandidate`, so behavior is identical; the point is that future callers who pass a warm cache benefit without any code path change.)

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/storage.ts
Line: 1405-1415

Comment:
**`haveEquivalentAccountRows` comment is slightly misleading**

the comment on `haveEquivalentAccountRows` says:
> `deduplicateAccounts()` keeps the last occurrence of duplicates

but `deduplicateAccountsByIdentity` actually keeps the DATA of the newest account (via `selectNewestAccount`) at the POSITION of the FIRST occurrence — not the last. the ordering guarantee that makes the index-aligned comparison correct is "first-seen slot order with newest-wins data", not "last occurrence position". the current wording could mislead future maintainers into assuming a `lastIndexOf`-style insertion order.

suggest updating the comment to clarify:

```suggestion
	// deduplicateAccounts() preserves the FIRST-SEEN position for each identity
	// but updates that slot with the newest account data (selectNewestAccount).
	// Incoming rows come after existing rows in the merged list, so they win when
	// both sides have the same identity — keeping the index-aligned comparison
	// correct for restore no-op detection.
	if (left.length !== right.length) {
		return false;
	}
```

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

Last reviewed commit: "fix(restore): revali..."

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 named backups and an interactive restore workflow via a new codex auth restore-backup command. adds storage APIs for listing, assessing, creating, and restoring named backups, ui menu/copy updates, cli wiring, and extensive tests. see lib/storage.ts:line, lib/codex-manager.ts:line, lib/cli.ts:line.

Changes

Cohort / File(s) Summary
documentation
docs/reference/commands.md, docs/reference/public-api.md, docs/reference/storage-paths.md
adds codex auth restore-backup docs, documents importAccounts() runtime shape including changed, and documents named backup paths and restore workflow.
cli types & entry
lib/cli.ts, lib/codex-manager.ts
adds "restore-backup" to LoginMode and wires the interactive restore manager, listing, assessment, confirmation, restore, and post-restore sync. see lib/cli.ts:line, lib/codex-manager.ts:line.
storage & backup apis
lib/storage.ts
introduces named-backup public APIs/types (NamedBackupMetadata, BackupRestoreAssessment, ImportAccountsResult), list/create/assess/restore helpers, path containment checks, transient-fs retry logic, concurrency limits, StorageError helpers, and __testOnly utilities; updates importAccounts() runtime return to include changed. see lib/storage.ts:line.
ui menu & copy
lib/ui/auth-menu.ts, lib/ui/copy.ts
adds restore-backup action and recovery menu entry; adds confirmation and success copy helpers used during restore flows. see lib/ui/auth-menu.ts:line, lib/ui/copy.ts:line.
cli tests
test/cli.test.ts
adds test for fallback aliases resolving to cancel when quit follows restore aliases. see test/cli.test.ts:line.
integration tests
test/codex-manager-cli.test.ts
large test scaffold covering menu-driven and direct restore-backup flows, non-interactive behavior, path validation, reassessment, concurrency, metadata refresh, and many edge cases. see test/codex-manager-cli.test.ts:line.
storage tests
test/storage.test.ts
adds comprehensive tests for named-backup creation, listing, containment, transient fs errors, concurrency, assessment, restore, and exposes test helpers/export surface. see test/storage.test.ts:line.

Sequence Diagram(s)

sequenceDiagram
    actor user
    participant cli as "cli / lib/codex-manager.ts"
    participant ui as "ui / lib/ui/*"
    participant storage as "storage / lib/storage.ts"
    participant sync as "codex sync"

    user->>cli: trigger restore-backup
    cli->>storage: listNamedBackups()
    storage-->>cli: backup metadata list
    cli->>ui: render backup menu
    user->>ui: select backup
    cli->>storage: assessNamedBackupRestore(name)
    storage-->>cli: assessment (eligible, impact, errors)
    cli->>ui: show assessment & confirm
    user->>ui: confirm restore
    cli->>storage: restoreAssessedNamedBackup(assessment)
    storage-->>cli: ImportAccountsResult { imported, skipped, total, changed? }
    alt sync enabled
        cli->>sync: attempt sync
        sync-->>cli: success or error
    end
    cli->>ui: show result
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~65 minutes

flags:

  • concurrency risks: review NAMED_BACKUP_* concurrency constants and pmap usage in lib/storage.ts:line and re-assessment vs restore race windows in lib/codex-manager.ts:line.
  • windows edge cases: verify path containment, backslash handling, symlink traversal, and drive-root escapes in lib/storage.ts:line and add targeted windows tests in test/storage.test.ts:line.
  • missing regression tests: ensure callers tolerate absent changed in ImportAccountsResult across codepaths in lib/storage.ts:line and add non-tty/non-interactive regression tests in test/cli.test.ts:line and test/codex-manager-cli.test.ts:line.
🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.17% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning PR description covers summary, what changed, validation checklist (all items marked complete), docs/reference updates, risk/rollback plan, and detailed notes. However, docs/upgrade.md was not updated despite importAccounts behavioral change. Add docs/upgrade.md entry documenting that importAccounts() now skips writes on no-op (changed===false), which breaks callers expecting unconditional persist() side-effect. Update docs/governance checklist to reflect this dependency.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title follows the conventional commits format with type 'feat', scope 'auth', and a concise lowercase summary under 72 characters. It clearly reflects the main change: adding backup restore manager functionality.

✏️ 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/02-backup-restore-manager
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch fresh/02-backup-restore-manager
📝 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

Adds a “backup restore manager” flow to the auth CLI, including a new direct codex auth restore-backup entrypoint, named-backup discovery/assessment/restore logic, and associated docs/tests.

Changes:

  • Introduces named-backup listing/assessment/restore APIs in lib/storage.ts (plus containment/path validation and transient FS retry handling).
  • Adds UI/menu & CLI wiring for “Restore From Backup” (interactive login menu + direct command).
  • Expands importAccounts() runtime result with a changed flag and updates tests/docs accordingly.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
test/storage.test.ts Adds coverage for import/export edge-cases and extensive named-backup restore scenarios (containment, transient FS errors, concurrency).
test/codex-manager-cli.test.ts Adds CLI-level tests for interactive restore menu + direct restore command behavior and error messaging.
test/cli.test.ts Verifies fallback input aliases map to the new restore-backup mode.
lib/ui/copy.ts Adds UI copy for recovery/restore prompts and updates fallback prompt text to include restore.
lib/ui/auth-menu.ts Adds a new “Recovery” section with “Restore From Backup” menu item.
lib/storage.ts Implements named-backup metadata, listing, containment validation, restore assessment/execution, import result changed, and transient FS retries.
lib/codex-manager.ts Wires restore manager into auth login flow and adds codex auth restore-backup command + interactive restore picker.
lib/cli.ts Extends login modes and fallback readline input parsing to support restore-backup.
docs/reference/storage-paths.md Documents named-backup paths and restore workflow/entrypoint.
docs/reference/public-api.md Documents the additive changed field on importAccounts() results.
docs/reference/commands.md Adds codex auth restore-backup to command reference.

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

lib/cli.ts Outdated
Comment on lines +237 to +244
if (
normalized === "u" ||
normalized === "backup" ||
normalized === "restore" ||
normalized === "restore-backup"
) {
return { mode: "restore-backup" };
}
Comment on lines +4336 to +4345
const ui = getUiRuntimeOptions();
const selection = await select(items, {
message: "Restore From Backup",
subtitle: backupDir,
help: UI_COPY.mainMenu.helpCompact,
clearScreen: true,
selectedEmphasis: "minimal",
focusStyle: displaySettings.menuFocusStyle ?? "row-invert",
theme: ui.theme,
});
Comment on lines +2196 to +2214
try {
builtPath = buildNamedBackupPath(requested);
} catch (error) {
// buildNamedBackupPath rejects names with special characters even when the
// requested backup name is a plain filename inside the backups directory.
// In that case, reporting ENOENT is clearer than surfacing the filename
// validator, but only when no separator/traversal token is present.
if (
requested.length > 0 &&
basename(requestedWithExtension) === requestedWithExtension &&
!requestedWithExtension.includes("..") &&
!/^[A-Za-z0-9_-]+$/.test(baseName)
) {
throw new Error(
`Import file not found: ${resolvePath(join(backupRoot, requestedWithExtension))}`,
);
}
throw error;
}
Comment on lines +1946 to +1969
function stableStringifyForComparison(value: unknown): string {
return JSON.stringify(canonicalizeComparisonValue(value));
}

function haveEquivalentAccountRows(
left: readonly unknown[],
right: readonly unknown[],
): boolean {
// deduplicateAccounts() keeps the last occurrence of duplicates, so incoming
// rows win when we compare merged restore data against the current snapshot.
// That keeps index-aligned comparison correct for restore no-op detection.
if (left.length !== right.length) {
return false;
}
for (let index = 0; index < left.length; index += 1) {
if (
stableStringifyForComparison(left[index]) !==
stableStringifyForComparison(right[index])
) {
return false;
}
}
return true;
}
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: 11

Caution

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

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

154-156: ⚠️ Potential issue | 🟡 Minor

fallback prompt lists restore-backup option but handler is missing.

the fallback prompt at lib/ui/copy.ts:155 shows (u) restore backup but promptLoginModeFallback in this file (lines 174-244) doesn't handle the u input. the tty-based showAuthMenu path at line 291-292 handles it, but non-tty users see the option without being able to use it.

either add a handler for u here or remove the (u) option from the fallback copy.

proposed fix to add handler
 			if (normalized === "q" || normalized === "quit")
 				return { mode: "cancel" };
+			if (normalized === "u" || normalized === "restore-backup" || normalized === "restore") {
+				return { mode: "restore-backup" };
+			}
 			console.log(UI_COPY.fallback.invalidModePrompt);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/cli.ts` around lines 154 - 156, The fallback prompt presents a "(u)
restore backup" option but promptLoginModeFallback does not handle input "u",
causing non-TTY users to see an unavailable choice; update
promptLoginModeFallback (the function that parses the fallback input) to
recognize "u" (and "U") and route it to the same restore-backup handler used by
the tty path (the same code invoked by showAuthMenu for the restore action), or
alternatively remove the "(u) restore backup" option from the fallback prompt in
lib/ui/copy.ts so the option is not shown to non-tty users; ensure you reference
and reuse the existing restore logic rather than duplicating 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 4430-4438: The restore error message should call out
Windows-specific EPERM/EBUSY transient-file-lock behavior and point users to the
retry logic in storage.ts (retryTransientFilesystemOperation) or troubleshooting
docs; update the catch block around collapseWhitespace/error handling in
codex-manager (the restore failure message) to append a parenthetical note like
"on Windows this may be caused by EPERM/EBUSY file locks — try closing other
Codex instances or see [troubleshooting]" and/or include a link to the existing
docs that explain retryTransientFilesystemOperation for Windows users.
- Around line 4394-4397: Update the boolean guard around the import result to be
explicit about the optional ImportAccountsResult.changed field: instead of
relying on falsy semantics (as seen in the block that logs "All accounts in this
backup already exist"), check the value explicitly (e.g., test result.changed
=== false or result.changed !== true depending on intended semantics) so
TypeScript strictNullChecks won't treat undefined the same as false; refer to
the ImportAccountsResult.changed definition (lib/storage.ts:1922) and update the
conditional in the function/method in codex-manager.ts where result.changed is
used.
- Around line 4228-4440: Add two regression tests exercising
runBackupRestoreManager: one where listNamedBackups returns an empty array to
hit the "No named backups found" branch (assert console.log contains the
backupDir message) and another where a successful restore path is followed but
autoSyncActiveAccountToCodex is mocked to throw or return false so the restore
branch logs the expected warning; stub/mocks should target listNamedBackups,
assessNamedBackupRestore/restoreAssessedNamedBackup (to simulate a restorable
backup), confirm (auto-confirm), and autoSyncActiveAccountToCodex, and your
tests should run the CLI entry that calls runBackupRestoreManager and assert the
appropriate console output was produced for both the empty-list and sync-failure
cases.

In `@lib/storage.ts`:
- Around line 1971-1978: namedBackupContainmentFs is exported as a live mutable
singleton (used where the module hands it out) allowing test or external code to
monkey-patch lstat/realpath and weaken containment; change the API to stop
exposing the raw object: implement and export either (a) a factory that returns
a fresh cloned facade object with lstat/realpath bound to
lstatSync/realpathSync.native, or (b) explicit setter/reset hooks (e.g.,
setBackupContainmentFs and resetBackupContainmentFs) that control a private
internal instance; update the code paths that currently return
namedBackupContainmentFs to call the factory or use the controlled accessor so
callers cannot mutate the module's live functions directly (refer to
namedBackupContainmentFs and the site that currently returns it).
- Around line 1721-1739: The sort comparator in backups.sort uses only computed
leftTime/rightTime so equal (including "unknown") mtimes are non-deterministic;
modify the comparator used in lib/storage.ts (the backups.sort closure
referencing left.updatedAt and right.updatedAt) to add a deterministic
tie-breaker (e.g., compare a stable key such as backup.id, backup.name, or
backup.path) when rightTime === leftTime so order is stable across filesystems;
update/extend the related vitest (test/codex-manager-cli.test.ts) to assert the
new deterministic ordering and mention the affected test ranges, and ensure the
change follows lib/** guidelines (auth rotation, Windows IO/concurrency
concerns) and that any new queuing behavior handles EBUSY/429 scenarios in
tests.
- Around line 2184-2215: Add regression tests in the CLI restore test suite that
call the restore flow using both a bare backup name (e.g., "named-backup") and
the dotted form ("named-backup.json") to exercise resolveNamedBackupRestorePath;
assert it resolves to the same valid path and that invalid dotted names are
rejected the same way as normalizeNamedBackupFileName enforces (cover allowed
chars and rejection of special chars/traversal). Specifically, add tests that
invoke the code path which uses resolveNamedBackupRestorePath (so
buildNamedBackupPath and normalizeNamedBackupFileName validations run) and
assert successful resolution for valid base names with and without the ".json"
suffix and proper ENOENT-style error behavior for invalid dotted names.

In `@test/cli.test.ts`:
- Around line 707-735: The test currently only checks that promptLoginMode([{
index: 0 }]) eventually returns { mode: "cancel" } after a second "quit" prompt,
which lets alias inputs (u/restore/backup/restore-backup) hide behind the
generic invalid-input loop; update the assertions so each alias-specific case
asserts the branch-specific rejection returned by promptLoginMode (for the
inputs "u", "restore", "backup", "restore-backup") instead of only the final
cancel — e.g. after calling promptLoginMode with mockRl.question mocked to
return the alias then "quit", assert the resolved value contains the
alias-rejection indicator (a specific property or message returned by
promptLoginMode that states the alias requires the interactive backup browser),
using promptLoginMode and mockRl.question to locate the behavior and ensuring
each alias is checked for that exact rejection text/property rather than only {
mode: "cancel" }.

In `@test/codex-manager-cli.test.ts`:
- Around line 4046-4066: The test currently only verifies
runNamedBackupExportMock was called with "../bad-name" but not that the CLI
rejected the invalid/colliding name; update the "rejects invalid or colliding
experimental backup filenames" test to assert the user-visible rejection or
collision outcome by checking one or more of: the CLI exit code reflects failure
(e.g., expect(exitCode).not.toBe(0) or specific code), a user-facing message was
printed via the logger
(expect(mockLogger.error/print).toHaveBeenCalledWith(expect.stringContaining("invalid")
or "collision")), or that the prompt flow continued/re-prompted
(expect(promptQuestionMock).toHaveBeenCalledTimes(>1) or
selectSequence.remaining() changed accordingly). Use the existing symbols
promptQuestionMock, runNamedBackupExportMock, runCodexMultiAuthCli,
selectSequence to locate where to add these assertions and make the test
deterministic under Vitest.

In `@test/storage.test.ts`:
- Around line 2159-2190: The test mixes two conflicting contracts:
assessNamedBackupRestore("../escaped-entry") and
restoreNamedBackup("../escaped-entry") are already rejected by the
name/separator validator, so the mocked readdir branch with escapedEntry is
never exercised; fix by either (A) removing the high-level assertions against
assessNamedBackupRestore and restoreNamedBackup and only asserting that the
readdir-mocked code path triggers an "escapes backup directory" error (i.e.,
keep the readdirSpy and test the lower-level containment behavior), or (B)
change the test input to a name that passes the separator validator (e.g.,
"escaped-entry" or another valid token) so that assessNamedBackupRestore and
restoreNamedBackup reach the readdir logic and then assert they throw the
"escapes backup directory" error; reference assessNamedBackupRestore,
restoreNamedBackup, readdirSpy and escapedEntry to locate the lines to modify.
- Around line 2060-2088: Add a deterministic regression test that reproduces the
stale-assessment TOCTOU on the public API by concurrently invoking
restoreNamedBackup (not just restoreAssessedNamedBackup) so the code path that
re-assesses inside restoreNamedBackup is exercised; specifically, create a named
backup via createNamedBackup after saving accounts up to
ACCOUNT_LIMITS.MAX_ACCOUNTS - 1, then spawn two parallel flows that call
restoreNamedBackup for the same name (one should succeed and change storage, the
other must detect the changed state and behave as a no-op/fail
deterministically), use the existing helpers saveAccounts,
assessNamedBackupRestore, removeWithRetry and loadAccounts to assert
post-conditions, implement synchronization (promises/await barriers) to force
the race deterministically under vitest, and assert the second call does not
cause account overflow or leave partial state (e.g., expect appropriate throw or
no-op and that loadAccounts shows correct account count).
- Around line 2447-2501: The test currently fabricates a Dirent rather than
creating a real symlink/junction, so lib functions listNamedBackups,
assessNamedBackupRestore, and restoreNamedBackup never exercise the
lstat/realpath containment checks; replace the Dirent mock with an actual
filesystem symlink (use fs.symlink or on Windows create a junction) named
"escaped-link.json" inside the backup root that points to the
externalBackupPath, run the three calls (listNamedBackups,
assessNamedBackupRestore, restoreNamedBackup) and assert they reject as before,
and ensure proper cleanup of the symlink in a finally block so the test is
deterministic and triggers the real lstat/realpath logic in lib/storage.ts.

---

Outside diff comments:
In `@lib/cli.ts`:
- Around line 154-156: The fallback prompt presents a "(u) restore backup"
option but promptLoginModeFallback does not handle input "u", causing non-TTY
users to see an unavailable choice; update promptLoginModeFallback (the function
that parses the fallback input) to recognize "u" (and "U") and route it to the
same restore-backup handler used by the tty path (the same code invoked by
showAuthMenu for the restore action), or alternatively remove the "(u) restore
backup" option from the fallback prompt in lib/ui/copy.ts so the option is not
shown to non-tty users; ensure you reference and reuse the existing restore
logic rather than duplicating 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: e768d2ed-7019-473a-adbd-03f461773e60

📥 Commits

Reviewing files that changed from the base of the PR and between af7f137 and 1eeec0b.

📒 Files selected for processing (11)
  • docs/reference/commands.md
  • docs/reference/public-api.md
  • docs/reference/storage-paths.md
  • lib/cli.ts
  • lib/codex-manager.ts
  • lib/storage.ts
  • lib/ui/auth-menu.ts
  • lib/ui/copy.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 (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.test.ts
  • test/storage.test.ts
  • test/codex-manager-cli.test.ts
docs/**

⚙️ CodeRabbit configuration file

keep README, SECURITY, and docs consistent with actual CLI flags and workflows. whenever behavior changes, require updated upgrade notes and mention new npm scripts.

Files:

  • docs/reference/commands.md
  • docs/reference/storage-paths.md
  • docs/reference/public-api.md
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/cli.ts
  • lib/ui/auth-menu.ts
  • lib/codex-manager.ts
  • lib/ui/copy.ts
  • lib/storage.ts
🔇 Additional comments (16)
docs/reference/commands.md (1)

29-29: documentation looks good for the new restore-backup command.

the command entry at line 29 and workflow example at line 115 are consistent with the implementation in lib/codex-manager.ts:4499-4512. no flags are documented, which matches the implementation that takes no arguments.

Also applies to: 115-115

lib/cli.ts (1)

291-292: switch case for restore-backup is correct.

the handler properly returns { mode: "restore-backup" } matching the LoginMenuResult interface. wiring is complete with lib/ui/auth-menu.ts:67 and consumed at lib/codex-manager.ts:3927.

docs/reference/public-api.md (1)

40-44: api contract documentation is accurate.

the additive compatibility note correctly documents that changed is optional in the type (see lib/storage.ts:1916-1923) while runtime always includes it. this preserves backward compatibility for callers modeling the legacy { imported, total, skipped } shape.

docs/reference/storage-paths.md (1)

25-25: storage paths documentation is complete and accurate.

the named backup paths at lines 25 and 60 match the implementation. the restore workflow at lines 105-114 correctly documents both the dashboard flow and the direct codex auth restore-backup entrypoint wired at lib/codex-manager.ts:4499.

Also applies to: 60-60, 105-114

lib/ui/copy.ts (2)

17-37: ui copy for restore-backup flows is well-structured.

the conditional message in restoreBackupConfirm (lines 26-28) clearly distinguishes metadata-only refreshes (netNewAccounts === 0) from actual imports. parameter order matches the call site at lib/codex-manager.ts:4382-4388.


155-156: fallback prompt updated but handler missing in cli.ts.

the (u) restore backup option is now listed in the prompt, but the corresponding input handler in lib/cli.ts:174-244 (promptLoginModeFallback) doesn't parse u input. see related comment on lib/cli.ts.

lib/ui/auth-menu.ts (2)

67-67: auth menu action type and focus key handler are correct.

the restore-backup variant at line 67 aligns with LoginMode in lib/cli.ts:60. the focus key handler at line 521 follows the existing pattern for action-type keys.

Also applies to: 521-521


660-670: recovery section placement and styling look good.

placing the restore-backup item in a dedicated "Recovery" section between accounts and danger zone is sensible ux. the yellow color appropriately signals a cautious action without being as alarming as danger-zone red.

lib/codex-manager.ts (8)

141-152: formatRelativeDateShort handles edge cases correctly.

the helper properly guards against null, undefined, 0, and non-finite values at lines 144-146. the date formatting logic is straightforward and consistent with similar helpers elsewhere.


4231-4236: non-interactive mode guard is correct and necessary.

blocking restore in non-interactive mode (isNonInteractiveMode()) at line 4231 prevents automated or ci-triggered restores without user confirmation. this aligns with the pr objective "block non-interactive backup restore mode".


4270-4304: chunked assessment with Promise.allSettled is resilient.

using Promise.allSettled at line 4276 ensures one failing assessment doesn't abort the entire list. containment errors are correctly re-thrown at line 4290 since they indicate a security boundary violation. the concurrency limit from NAMED_BACKUP_ASSESS_CONCURRENCY prevents overwhelming the filesystem.


4363-4378: re-assessment before restore mitigates toctou race.

the re-assessment at lines 4365-4368 checks current storage state immediately before restore, addressing the time-of-check-to-time-of-use concern noted in pr objectives. if eligibility changed between menu selection and confirmation, the user sees the updated error at line 4377.


4414-4428: codex cli sync failures are appropriately handled as warnings.

the sync at line 4415 is wrapped in try/catch and failures are logged as warnings rather than errors. this is correct since the restore itself succeeded - sync failure is a degraded state, not a fatal error.


4499-4512: cli command handler is correctly wired.

the restore-backup command at line 4499 follows the same pattern as other commands: set storage path, run manager, handle errors. exit codes are correct (0 for success, 1 for failure).


3881-3892: restore-backup correctly drains pending quota refresh.

adding restore-backup to modeRequiresDrainedQuotaRefresh at line 3886 ensures the pending quota cache refresh completes before attempting restore. this prevents potential race conditions where restore modifies account storage while quota refresh is still reading it.


4228-4440: no sensitive data leakage in restore manager logging.

reviewed all console output in runBackupRestoreManager: only backup names, account counts, and sanitized error messages are logged. no tokens, emails, or other pii are exposed.

Comment on lines +4430 to +4438
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const collapsedMessage = collapseWhitespace(message) || "unknown error";
console.error(
/exceed maximum/i.test(collapsedMessage)
? `Restore failed: ${collapsedMessage}. Close other Codex instances and try again.`
: `Restore failed: ${collapsedMessage}`,
);
return false;
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

error message for concurrent access could mention windows specifically.

the "Close other Codex instances" suggestion at line 4435 is helpful but doesn't explain windows-specific EPERM/EBUSY behavior. per pr notes, retryTransientFilesystemOperation handles windows retry in storage.ts. consider expanding the message for windows users or linking to troubleshooting docs.

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

In `@lib/codex-manager.ts` around lines 4430 - 4438, The restore error message
should call out Windows-specific EPERM/EBUSY transient-file-lock behavior and
point users to the retry logic in storage.ts (retryTransientFilesystemOperation)
or troubleshooting docs; update the catch block around collapseWhitespace/error
handling in codex-manager (the restore failure message) to append a
parenthetical note like "on Windows this may be caused by EPERM/EBUSY file locks
— try closing other Codex instances or see [troubleshooting]" and/or include a
link to the existing docs that explain retryTransientFilesystemOperation for
Windows users.

Comment on lines +1971 to +1978
const namedBackupContainmentFs = {
lstat(path: string) {
return lstatSync(path);
},
realpath(path: string) {
return realpathSync.native(path);
},
};
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

don't export the live containment fs singleton.

lib/storage.ts:1971-1978 is handed out directly through lib/storage.ts:3189-3191. any vitest or internal caller can patch lstat or realpath and silently weaken containment checks for later restores in the same process. that can bleed across the containment regressions in test/codex-manager-cli.test.ts:2672-2738. expose explicit set/reset hooks or a cloned facade instead of the mutable object itself.

Also applies to: 3189-3191

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

In `@lib/storage.ts` around lines 1971 - 1978, namedBackupContainmentFs is
exported as a live mutable singleton (used where the module hands it out)
allowing test or external code to monkey-patch lstat/realpath and weaken
containment; change the API to stop exposing the raw object: implement and
export either (a) a factory that returns a fresh cloned facade object with
lstat/realpath bound to lstatSync/realpathSync.native, or (b) explicit
setter/reset hooks (e.g., setBackupContainmentFs and resetBackupContainmentFs)
that control a private internal instance; update the code paths that currently
return namedBackupContainmentFs to call the factory or use the controlled
accessor so callers cannot mutate the module's live functions directly (refer to
namedBackupContainmentFs and the site that currently returns it).

Comment on lines +4046 to 4066
it("rejects invalid or colliding experimental backup filenames", async () => {
const now = Date.now();
setupInteractiveSettingsLogin(createSettingsStorage(now));
promptQuestionMock.mockResolvedValueOnce("../bad-name");
runNamedBackupExportMock.mockResolvedValueOnce({ kind: "collision", path: "/mock/backups/bad-name.json" });
const selectSequence = queueSettingsSelectSequence([
{ type: "experimental" },
{ type: "backup" },
{ type: "back" },
{ type: "back" },
{ type: "back" },
]);

const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js");
const exitCode = await runCodexMultiAuthCli(["auth", "login"]);

expect(exitCode).toBe(0);
expect(selectSequence.remaining()).toBe(0);
expect(promptQuestionMock).toHaveBeenCalledOnce();
expect(runNamedBackupExportMock).toHaveBeenCalledWith({ name: "../bad-name" });
});
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

this doesn't verify the rejection path yet.

test/codex-manager-cli.test.ts:4046-4066 only proves that runNamedBackupExportMock was called with ../bad-name. it still passes if the ui accepts the invalid name, swallows the collision warning, or exits the flow early. assert the user-visible rejection or collision outcome as well.

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 4046 - 4066, The test currently
only verifies runNamedBackupExportMock was called with "../bad-name" but not
that the CLI rejected the invalid/colliding name; update the "rejects invalid or
colliding experimental backup filenames" test to assert the user-visible
rejection or collision outcome by checking one or more of: the CLI exit code
reflects failure (e.g., expect(exitCode).not.toBe(0) or specific code), a
user-facing message was printed via the logger
(expect(mockLogger.error/print).toHaveBeenCalledWith(expect.stringContaining("invalid")
or "collision")), or that the prompt flow continued/re-prompted
(expect(promptQuestionMock).toHaveBeenCalledTimes(>1) or
selectSequence.remaining() changed accordingly). Use the existing symbols
promptQuestionMock, runNamedBackupExportMock, runCodexMultiAuthCli,
selectSequence to locate where to add these assertions and make the test
deterministic under Vitest.

Comment on lines +2060 to +2088
it("re-resolves an assessed named backup before the final import", async () => {
await saveAccounts({
version: 3,
activeIndex: 0,
accounts: [
{
accountId: "deleted-helper",
refreshToken: "ref-deleted-helper",
addedAt: 1,
lastUsed: 1,
},
],
});

const backup = await createNamedBackup("deleted-helper-assessment");
await clearAccounts();

const assessment = await assessNamedBackupRestore(
"deleted-helper-assessment",
);
expect(assessment.eligibleForRestore).toBe(true);

await removeWithRetry(backup.path, { force: true });

await expect(restoreAssessedNamedBackup(assessment)).rejects.toThrow(
/Import file not found/,
);
expect((await loadAccounts())?.accounts ?? []).toHaveLength(0);
});
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

the stale-assessment race is still untested on the public restore api.

test/storage.test.ts:2060-2088 only covers restoreAssessedNamedBackup() when the backup file disappears. the concurrency regression in test/storage.test.ts:3214-3283 races restoreNamedBackup(), which re-assesses internally. that leaves the real toctou surface unpinned: two precomputed assessments can both look eligible at ACCOUNT_LIMITS.MAX_ACCOUNTS - 1, then one import changes storage before the second restoreAssessedNamedBackup() runs. add that regression so the mutex/no-op behavior is locked down on the exported api, not just the wrapper.

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: 3214-3283

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

In `@test/storage.test.ts` around lines 2060 - 2088, Add a deterministic
regression test that reproduces the stale-assessment TOCTOU on the public API by
concurrently invoking restoreNamedBackup (not just restoreAssessedNamedBackup)
so the code path that re-assesses inside restoreNamedBackup is exercised;
specifically, create a named backup via createNamedBackup after saving accounts
up to ACCOUNT_LIMITS.MAX_ACCOUNTS - 1, then spawn two parallel flows that call
restoreNamedBackup for the same name (one should succeed and change storage, the
other must detect the changed state and behave as a no-op/fail
deterministically), use the existing helpers saveAccounts,
assessNamedBackupRestore, removeWithRetry and loadAccounts to assert
post-conditions, implement synchronization (promises/await barriers) to force
the race deterministically under vitest, and assert the second call does not
cause account overflow or leave partial state (e.g., expect appropriate throw or
no-op and that loadAccounts shows correct account count).

Comment on lines +2447 to +2501
it("ignores symlink-like named backup entries that point outside the backups root", async () => {
const backupRoot = join(dirname(testStoragePath), "backups");
const externalBackupPath = join(testWorkDir, "outside-backup.json");
await fs.mkdir(backupRoot, { recursive: true });
await fs.writeFile(
externalBackupPath,
JSON.stringify({
version: 3,
activeIndex: 0,
accounts: [
{
accountId: "outside-manual-backup",
refreshToken: "ref-outside-manual-backup",
addedAt: 1,
lastUsed: 1,
},
],
}),
"utf-8",
);

const originalReaddir = fs.readdir.bind(fs);
const readdirSpy = vi.spyOn(fs, "readdir");
const escapedEntry = {
name: "escaped-link.json",
isFile: () => false,
isSymbolicLink: () => true,
} as unknown as Awaited<
ReturnType<typeof fs.readdir>
>[number];
readdirSpy.mockImplementation(async (...args) => {
const [path, options] = args;
if (
String(path) === backupRoot &&
typeof options === "object" &&
options?.withFileTypes === true
) {
return [escapedEntry] as Awaited<ReturnType<typeof fs.readdir>>;
}
return originalReaddir(...(args as Parameters<typeof fs.readdir>));
});

try {
const backups = await listNamedBackups();
expect(backups).toEqual([]);
await expect(assessNamedBackupRestore("escaped-link")).rejects.toThrow(
/not a regular backup file/i,
);
await expect(restoreNamedBackup("escaped-link")).rejects.toThrow(
/not a regular backup file/i,
);
} finally {
readdirSpy.mockRestore();
}
});
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

this does not reproduce the symlink escape you are naming here.

test/storage.test.ts:2449-2466 creates externalBackupPath, but test/storage.test.ts:2470-2487 never links it into backupRoot; the test only feeds a fabricated Dirent with isSymbolicLink() === true. that misses the real lstat/realpath containment path in lib/storage.ts:2126-2160, and it will not catch win32 junction regressions. please drive this through an actual symlink/junction under backupRoot and assert listNamedBackups, assessNamedBackupRestore, and restoreNamedBackup reject it end to end.

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 2447 - 2501, The test currently fabricates
a Dirent rather than creating a real symlink/junction, so lib functions
listNamedBackups, assessNamedBackupRestore, and restoreNamedBackup never
exercise the lstat/realpath containment checks; replace the Dirent mock with an
actual filesystem symlink (use fs.symlink or on Windows create a junction) named
"escaped-link.json" inside the backup root that points to the
externalBackupPath, run the three calls (listNamedBackups,
assessNamedBackupRestore, restoreNamedBackup) and assert they reject as before,
and ensure proper cleanup of the symlink in a finally block so the test is
deterministic and triggers the real lstat/realpath logic in lib/storage.ts.

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

4133-4153: ⚠️ Potential issue | 🟡 Minor

test still doesn't verify the rejection outcome.

this test only asserts that runNamedBackupExportMock was called with ../bad-name (line 4152). it passes even if the ui swallows the collision result silently or exits without user feedback. add an assertion for the user-visible outcome:

  • assert that a warning/error was logged (e.g., expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("collision")))
  • or verify the prompt re-prompts / flow continues appropriately

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.

🛠️ suggested fix
 	it("rejects invalid or colliding experimental backup filenames", async () => {
 		const now = Date.now();
 		setupInteractiveSettingsLogin(createSettingsStorage(now));
 		promptQuestionMock.mockResolvedValueOnce("../bad-name");
 		runNamedBackupExportMock.mockResolvedValueOnce({ kind: "collision", path: "/mock/backups/bad-name.json" });
+		const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
 		const selectSequence = queueSettingsSelectSequence([
 			{ type: "experimental" },
 			{ type: "backup" },
 			{ type: "back" },
 			{ type: "back" },
 			{ type: "back" },
 		]);
 
-		const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js");
-		const exitCode = await runCodexMultiAuthCli(["auth", "login"]);
+		try {
+			const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js");
+			const exitCode = await runCodexMultiAuthCli(["auth", "login"]);
 
-		expect(exitCode).toBe(0);
-		expect(selectSequence.remaining()).toBe(0);
-		expect(promptQuestionMock).toHaveBeenCalledOnce();
-		expect(runNamedBackupExportMock).toHaveBeenCalledWith({ name: "../bad-name" });
+			expect(exitCode).toBe(0);
+			expect(selectSequence.remaining()).toBe(0);
+			expect(promptQuestionMock).toHaveBeenCalledOnce();
+			expect(runNamedBackupExportMock).toHaveBeenCalledWith({ name: "../bad-name" });
+			expect(warnSpy).toHaveBeenCalledWith(
+				expect.stringContaining("collision"),
+			);
+		} finally {
+			warnSpy.mockRestore();
+		}
 	});
🤖 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 4133 - 4153, The test "rejects
invalid or colliding experimental backup filenames" currently only verifies
runNamedBackupExportMock was called but not that the UI handled the "collision"
result; update the test to assert the user-visible outcome by adding a check
that the warning logger (e.g., warnSpy) was called with a message containing
"collision" or that the prompt re-ran (e.g., promptQuestionMock called more than
once or selectSequence progressed accordingly); reference
runNamedBackupExportMock and promptQuestionMock in the new assertion so the test
deterministically verifies the collision path and user feedback.
🤖 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/storage.ts`:
- Around line 2077-2165: resolvePathForNamedBackupContainment and
assertNamedBackupRestorePath currently convert EPERM/EBUSY into
BackupPathValidationTransientError but are not retried like
readdir/readFile/stat, causing one-off Windows AV/lock failures to surface as
hard errors; wrap the containment validation calls in the same retry/backoff
helper used for directory reads (the retry used around readdir/readFile/stat) or
refactor resolvePathForNamedBackupContainment to call that helper for
lstat/realpath operations and ensure assertNamedBackupRestorePath invokes the
retryable variant; add a vitest regression that simulates a one-shot EBUSY/EPERM
on lstat/realpath during a list+restore flow to assert the operation succeeds
after a single retry and update related tests to reference the new retryable
containment functions.
- Around line 1882-1900: The current restoreAssessedNamedBackup re-resolves the
backup by name, creating a TOCTOU risk; change its API and callers so
restoreAssessedNamedBackup receives the assessed backup path and a stable
fingerprint (use fields from BackupRestoreAssessment like backup.path and a
digest/timestamp from the assessment) and call importAccounts with that resolved
path instead of calling resolveNamedBackupRestorePath again; update
restoreNamedBackup to forward assessNamedBackupRestore()'s backup.path and
fingerprint into restoreAssessedNamedBackup, add a vitest that performs an
assessment, swaps the file contents (simulate replace) before restore and
asserts the fingerprint prevents restoring the new content, and ensure the
change accounts for Windows EBUSY and concurrency by retrying path reads on
transient errors (EBUSY/429) per IO retry policy.

In `@test/codex-manager-cli.test.ts`:
- Around line 530-567: The default mock setup creates an implicit dependency
chain where restoreAssessedNamedBackupMock calls
resolveNamedBackupRestorePathMock and then importAccountsMock (see
restoreAssessedNamedBackupMock, resolveNamedBackupRestorePathMock,
importAccountsMock and assessNamedBackupRestoreMock); add a brief inline comment
next to this mock implementation explaining that restoreAssessedNamedBackupMock
delegates to importAccountsMock via resolveNamedBackupRestorePathMock and that
some tests override importAccountsMock directly (so they depend on this chain),
so maintainers know not to break the linkage when changing mocks.

---

Duplicate comments:
In `@test/codex-manager-cli.test.ts`:
- Around line 4133-4153: The test "rejects invalid or colliding experimental
backup filenames" currently only verifies runNamedBackupExportMock was called
but not that the UI handled the "collision" result; update the test to assert
the user-visible outcome by adding a check that the warning logger (e.g.,
warnSpy) was called with a message containing "collision" or that the prompt
re-ran (e.g., promptQuestionMock called more than once or selectSequence
progressed accordingly); reference runNamedBackupExportMock and
promptQuestionMock in the new assertion so the test deterministically verifies
the collision path and user feedback.
🪄 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: 9decb3d0-27f9-47c8-b9c9-d8e9aee26dee

📥 Commits

Reviewing files that changed from the base of the PR and between 1eeec0b and f62fa95.

📒 Files selected for processing (6)
  • lib/codex-manager.ts
  • lib/storage.ts
  • lib/ui/copy.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)
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
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/storage.ts
  • lib/ui/copy.ts
🔇 Additional comments (20)
lib/ui/copy.ts (1)

17-37: lgtm — ui copy for restore-backup flow.

new copy entries for recovery/restore-backup are straightforward. template functions correctly interpolate counts and names. no sensitive data exposure risk here.

test/cli.test.ts (1)

707-744: test still passes on the generic invalid-input loop rather than the alias-specific rejection.

this test verifies that u, restore, backup, restore-backup all eventually resolve to { mode: "cancel" } after a second quit input, and that UI_COPY.fallback.invalidModePrompt is logged 4 times. however, it does not assert the branch-specific rejection message (e.g., "restore-backup requires the interactive backup browser") that distinguishes these aliases from any other invalid input. if the implementation just falls through to the generic invalid loop, the test passes; if someone later removes the alias handling entirely, the test still passes.

add an assertion on the alias-specific rejection copy (or a distinct property in the resolved value) so this test actually guards the new alias handling. 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.

test/storage.test.ts (12)

1-44: imports look correct for new named-backup test coverage.

added resolve, ACCOUNT_LIMITS, removeWithRetry, and the named-backup api surface from lib/storage.js. these are all exercised in the new tests.


379-440: good transaction boundary tests including win32 case-insensitive path handling.

test/storage.test.ts:407-440 correctly mocks process.platform to win32 and verifies that case-only path differences are allowed inside a transaction. cleanup is handled in finally block.


2223-2254: these assertions encode conflicting contracts for the same input shape.

test/storage.test.ts:2170-2179 already establishes that assessNamedBackupRestore("../...") and restoreNamedBackup("../...") fail fast on the separator validator with "must not contain path separators". at test/storage.test.ts:2245-2250, the test expects a different error ("escapes backup directory") for another ../... input, but the separator check rejects first, so the mocked readdir branch is never reached.

use an input that survives the name validator if you want to exercise the containment check on directory entries returned by readdir, or drop the high-level assertions here and keep only the lower-level containment coverage.


2511-2565: this does not reproduce the symlink escape you are naming here.

test/storage.test.ts:2511-2530 creates externalBackupPath but never links it into backupRoot. the test only feeds a fabricated Dirent with isSymbolicLink() === true, which misses the real lstat/realpath containment path in lib/storage.ts. lines 2256-2346 test assertNamedBackupRestorePath with real symlinks, but listNamedBackups, assessNamedBackupRestore, and restoreNamedBackup are not driven through an actual symlink/junction under backupRoot. this won't catch win32 junction regressions.

drive this through an actual symlink/junction under backupRoot and assert the end-to-end api calls reject it.


2112-2140: the stale-assessment race is still untested on the public restoreAssessedNamedBackup api.

test/storage.test.ts:2112-2140 covers the case where the backup file disappears after assessment. test/storage.test.ts:3278-3347 races restoreNamedBackup(), which re-assesses internally. but the real toctou surface is not pinned: two precomputed assessments can both look eligible at ACCOUNT_LIMITS.MAX_ACCOUNTS - 1, then one import changes storage before the second restoreAssessedNamedBackup() runs.

add that regression so the mutex/no-op behavior is locked down on the exported api, not just the wrapper. as per coding guidelines, test/**: demand regression cases that reproduce concurrency bugs.


3278-3347: good concurrent restore serialization test.

test/storage.test.ts:3278-3347 correctly races two restoreNamedBackup calls when limit is tight and verifies one succeeds while the other fails with "Import would exceed maximum". this exercises the internal mutex behavior. (the stale-assessment race on restoreAssessedNamedBackup is flagged separately.)


2256-2346: good real-symlink containment tests for assertNamedBackupRestorePath.

test/storage.test.ts:2256-2346 creates actual symlinks/junctions and validates that assertNamedBackupRestorePath rejects paths escaping the backup root. platform-specific symlink types are handled (junction on win32). this covers the low-level containment logic well.


2605-2623: good retry behavior test for EAGAIN on non-windows.

test/storage.test.ts:2605-2623 mocks platform to linux, injects EAGAIN errors on readdir, and verifies 7 retry attempts. deterministic and properly restored in finally block.


2774-2860: good chunked backup read retry test.

test/storage.test.ts:2774-2860 creates backups exceeding the chunk boundary, injects a transient EBUSY on the last backup read, and verifies retry succeeds. properly cleans up spies in finally block.


3081-3119: good candidate cache reuse test.

test/storage.test.ts:3081-3119 verifies that listNamedBackups populates the candidate cache and assessNamedBackupRestore reuses it, avoiding duplicate readFile calls. proper spy cleanup.


3224-3276: good concurrency limit test for backup reads.

test/storage.test.ts:3224-3276 creates backups exceeding the concurrency limit, adds delays to readFile, and verifies peak concurrent reads stay within NAMED_BACKUP_LIST_CONCURRENCY. the timing-based approach is reasonable for this type of test.


1950-2002: good metadata-only restore eligibility test.

test/storage.test.ts:1950-2002 verifies that a backup with fresher metadata is marked eligibleForRestore = true even when imported = 0, and that the restore actually updates stored metadata (accessToken, lastUsed).

test/codex-manager-cli.test.ts (6)

3388-3463: concurrency test is well-designed.

imports the actual NAMED_BACKUP_ASSESS_CONCURRENCY constant from lib/storage.js:3391-3394 and verifies maxInFlight never exceeds it. the deferred-gate pattern with queueMicrotask batching is deterministic since all gates release together before assertion. good coverage for the chunked-concurrency behavior mentioned in the PR summary.


2701-2757: EBUSY error path tested correctly.

the test at test/codex-manager-cli.test.ts:2701 covers transient filesystem errors with makeErrnoError("backup busy", "EBUSY") and verifies the cli returns non-zero with proper error messaging. this addresses one of the windows-related concerns from the greptile review.


2393-2459: restore flow tests are comprehensive.

both "restores a named backup from the login recovery menu" and "restores a named backup from the direct restore-backup command" cover:

  • listNamedBackupsMock called once
  • assessNamedBackupRestoreMock called with expected args including currentStorage
  • confirmMock called once
  • importAccountsMock called with resolved path
  • setCodexCliActiveSelectionMock called for post-restore sync

assertions are specific and deterministic.

Also applies to: 2461-2526


3649-3744: reassessment eligibility change is tested.

the test "returns to the login menu when backup reassessment becomes ineligible" at test/codex-manager-cli.test.ts:3649 correctly verifies that when eligibleForRestore flips to false on reassessment, the flow aborts without calling confirmMock or importAccountsMock. this addresses the eligibleForRestore guard concern from the PR summary.


5145-5265: race condition tests for quota refresh coordination.

both "waits for an in-flight menu quota refresh before starting quick check" and "waits for an in-flight menu quota refresh before starting backup restore" use the deferred pattern to verify that:

  1. menu actions wait for in-flight fetches to complete
  2. saveQuotaCacheMock.mock.invocationCallOrder[0] precedes the action's mock call

these are solid concurrency regression tests per coding guidelines.

Also applies to: 5267-5355


1-10: symlink regression tests already exist throughout the test suite and address windows filesystem edge cases.

test/named-backup-export.test.ts:186 ("rejects backup roots that escape through a symlinked backups directory") and test/storage.test.ts:2256 ("rejects backup paths whose real path escapes the backups directory through symlinked directories") already cover the exact scenario you suggested—symlinks that resolve outside the backup directory. both tests handle windows by using junction points (process.platform === "win32" ? "junction" : "dir"). further, test/storage.test.ts:2313 validates symlinked backup roots during restore, and test/paths.test.ts covers realpathSync fallback behavior on windows.

			> Likely an incorrect or invalid review comment.

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.

♻️ Duplicate comments (6)
lib/storage.ts (3)

3200-3202: ⚠️ Potential issue | 🟠 Major

do not export the live containment fs singleton.

lib/storage.ts:3200-3202 exposes a mutable process-wide object. tests or callers can monkey-patch lstat/realpath and weaken containment checks for later restores in the same process. expose controlled set/reset hooks (test-only) or return a frozen clone instead.

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.

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

In `@lib/storage.ts` around lines 3200 - 3202, The export is exposing the live
mutable singleton namedBackupContainmentFs via __testOnly; instead, stop
exporting the live object and replace it with controlled test-only accessors:
provide functions like getNamedBackupContainmentFs() that return a deep-frozen
clone and setNamedBackupContainmentFsForTest(fs) +
resetNamedBackupContainmentFsForTest() to install/reset a mock in a controlled
way; update references to call the getter where needed, mark the new functions
as test-only, and update vitest tests to use set/reset hooks so no test can
monkey-patch the real process-wide object (also verify related tests handle
Windows FS IO, auth rotation, and concurrency scenarios including EBUSY/429
retries).

1977-1984: ⚠️ Potential issue | 🟠 Major

containment checks are still sync and transient path-validation errors are not retried.

lib/storage.ts:1977-1984 uses lstatSync/realpathSync.native on interactive restore/list paths, and lib/storage.ts:2083-2171 converts transient lock errors into BackupPathValidationTransientError without a retry loop at the containment layer. on windows, one-shot av/file-lock races can still fail list/restore, and sync calls can stall the menu thread.

#!/bin/bash
set -euo pipefail

echo "== containment fs sync usage =="
rg -n "lstatSync|realpathSync\\.native" lib/storage.ts

echo "== transient containment error paths =="
rg -n "BackupPathValidationTransientError|resolvePathForNamedBackupContainment|assertNamedBackupRestorePath" lib/storage.ts

echo "== retry helper callsites in named-backup flow =="
rg -n "retryTransientFilesystemOperation\\(" lib/storage.ts

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.

Also applies to: 2083-2171

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

In `@lib/storage.ts` around lines 1977 - 1984, The containment FS currently uses
blocking lstatSync/realpathSync.native in namedBackupContainmentFs and does not
retry transient lock races: replace those sync calls with async fs.promises
equivalents inside namedBackupContainmentFs (use async lstat and realpath), and
wrap them with the existing retryTransientFilesystemOperation helper so
transient errors (EBUSY/EPERM/ETIMEDOUT/429-like conditions) are retried; ensure
resolvePathForNamedBackupContainment and assertNamedBackupRestorePath propagate
BackupPathValidationTransientError when the retry helper exhausts attempts,
update affected callsites to await the async containment methods, and add/update
vitest cases to cover Windows EBUSY/file-lock races and that retries occur.

1895-1906: ⚠️ Potential issue | 🟠 Major

carry assessed backup identity into restore to close the assess/restore toctou window.

lib/storage.ts:1903-1905 resolves by assessment.backup.name again, so a file swap after confirmation can restore different content than what was assessed. this is still the same race surfaced in earlier feedback. also add a vitest that swaps a backup to a different valid payload between assess and restoreAssessedNamedBackup(); the current tests around test/storage.test.ts:2084-2168 only cover delete/invalid-json paths.

possible fix
 export async function restoreAssessedNamedBackup(
 	assessment: Pick<BackupRestoreAssessment, "backup" | "eligibleForRestore" | "error">,
 ): Promise<ImportAccountsResult> {
@@
-	const resolvedPath = await resolveNamedBackupRestorePath(
-		assessment.backup.name,
-	);
+	const backupRoot = getNamedBackupRoot(getStoragePath());
+	const resolvedPath = assertNamedBackupRestorePath(
+		assessment.backup.path,
+		backupRoot,
+	);
+	const currentMetadata = await buildNamedBackupMetadata(
+		assessment.backup.name,
+		resolvedPath,
+	);
+	if (
+		currentMetadata.updatedAt !== assessment.backup.updatedAt ||
+		currentMetadata.sizeBytes !== assessment.backup.sizeBytes
+	) {
+		throw new Error(
+			"backup changed after confirmation. re-assess and retry restore.",
+		);
+	}
 	return importAccounts(resolvedPath);
 }

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.

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

In `@lib/storage.ts` around lines 1895 - 1906, The restoreAssessedNamedBackup
function currently re-resolves the backup by name (calling
resolveNamedBackupRestorePath inside restoreAssessedNamedBackup), opening a
TOCTOU where the file at that name could be swapped after assessment; change the
flow to carry the assessed identity into the restore call (e.g., include the
resolved path or a content hash in the BackupRestoreAssessment and use that
stored value instead of re-resolving) so restoreAssessedNamedBackup uses
assessment's vetted identifier (use symbols: restoreAssessedNamedBackup,
BackupRestoreAssessment, resolveNamedBackupRestorePath, importAccounts); update
tests by adding a vitest that swaps the backup file to a different valid payload
between assess and restore (extend test/storage.test.ts around the existing
tests at 2084-2168) to assert the restore uses the original assessed content and
fails if swapped.
test/storage.test.ts (3)

2511-2565: ⚠️ Potential issue | 🟠 Major

replace dirent fabrication with a real symlink/junction regression.

test/storage.test.ts:2534-2551 mocks a symlink-like Dirent, so it does not exercise real lstat/realpath containment behavior end-to-end. create an actual symlink/junction under backup root and run listNamedBackups, assessNamedBackupRestore, and restoreNamedBackup against it.

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 2511 - 2565, Replace the fabricated Dirent
hack with a real filesystem symlink/junction: instead of mocking fs.readdir,
create an actual link file named "escaped-link.json" inside backupRoot that
points to externalBackupPath (use fs.symlink with type 'junction' on Windows)
before calling listNamedBackups, assessNamedBackupRestore("escaped-link"), and
restoreNamedBackup("escaped-link"); assert listNamedBackups returns [] and the
two restore/assess calls reject with /not a regular backup file/i, then remove
the symlink in a finally block to clean up. Ensure the test creates backupRoot
and externalBackupPath as shown, uses the same identifiers used in the diff
(backupRoot, externalBackupPath, escaped-link.json, listNamedBackups,
assessNamedBackupRestore, restoreNamedBackup), and avoids mocking filesystem
APIs so lstat/realpath containment checks run end-to-end.

2223-2254: ⚠️ Potential issue | 🟠 Major

this assertion path uses an impossible filename shape and conflicts with separator-validator coverage.

test/storage.test.ts:2228 fabricates a Dirent named "../escaped-entry.json", which cannot exist as a real directory entry. it also conflicts with the explicit separator-validator expectations in test/storage.test.ts:2170-2180. use a valid input that reaches containment checks, or keep this as a lower-level containment-only test without contradictory high-level name-validation assertions.

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 2223 - 2254, The test currently fabricates
a Dirent with an impossible name ("../escaped-entry.json") which conflicts with
the higher-level separator validation; change the fake entry so its name is a
valid leaf (e.g. "escaped-entry.json") and simulate an escape via resolution
(make the Dirent report isSymbolicLink() true and mock the follow-up resolution
calls—e.g., fs.readlink or the code path that resolves the entry to an absolute
path—to return a path outside backupRoot) so assessNamedBackupRestore and
restoreNamedBackup exercise the containment checks without violating
name-validator expectations; update references in the test to the same symbols
(readdirSpy, escapedEntry, backupRoot) and keep the restore/assert logic intact.

2112-2140: ⚠️ Potential issue | 🟠 Major

this test locks in stale-assessment behavior instead of guarding against a changed backup payload.

test/storage.test.ts:2112-2140 verifies re-resolution after deletion, but it does not pin the higher-risk case where the backup remains present and changes to different valid json between assess and restore. add a deterministic regression for that race on restoreAssessedNamedBackup().

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 2112 - 2140, The test currently only
checks deletion between assess and restore; add a deterministic regression where
the backup file is still present but mutated to a different valid JSON between
assessNamedBackupRestore() and restoreAssessedNamedBackup() to ensure restore
re-resolves and fails appropriately. Specifically, in the same test (or a new
one), after creating the named backup via createNamedBackup("...") and calling
assessNamedBackupRestore(...) (store the returned assessment and backup.path),
overwrite the backup file at backup.path with a different valid JSON payload
(using fs.writeFileSync or the test helper used elsewhere) before calling
restoreAssessedNamedBackup(assessment), then assert that
restoreAssessedNamedBackup rejects (or returns the expected error) and that
loadAccounts() remains unchanged; reference functions createNamedBackup,
assessNamedBackupRestore, restoreAssessedNamedBackup, removeWithRetry,
saveAccounts, and loadAccounts so the change is easy to locate.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@lib/storage.ts`:
- Around line 3200-3202: The export is exposing the live mutable singleton
namedBackupContainmentFs via __testOnly; instead, stop exporting the live object
and replace it with controlled test-only accessors: provide functions like
getNamedBackupContainmentFs() that return a deep-frozen clone and
setNamedBackupContainmentFsForTest(fs) + resetNamedBackupContainmentFsForTest()
to install/reset a mock in a controlled way; update references to call the
getter where needed, mark the new functions as test-only, and update vitest
tests to use set/reset hooks so no test can monkey-patch the real process-wide
object (also verify related tests handle Windows FS IO, auth rotation, and
concurrency scenarios including EBUSY/429 retries).
- Around line 1977-1984: The containment FS currently uses blocking
lstatSync/realpathSync.native in namedBackupContainmentFs and does not retry
transient lock races: replace those sync calls with async fs.promises
equivalents inside namedBackupContainmentFs (use async lstat and realpath), and
wrap them with the existing retryTransientFilesystemOperation helper so
transient errors (EBUSY/EPERM/ETIMEDOUT/429-like conditions) are retried; ensure
resolvePathForNamedBackupContainment and assertNamedBackupRestorePath propagate
BackupPathValidationTransientError when the retry helper exhausts attempts,
update affected callsites to await the async containment methods, and add/update
vitest cases to cover Windows EBUSY/file-lock races and that retries occur.
- Around line 1895-1906: The restoreAssessedNamedBackup function currently
re-resolves the backup by name (calling resolveNamedBackupRestorePath inside
restoreAssessedNamedBackup), opening a TOCTOU where the file at that name could
be swapped after assessment; change the flow to carry the assessed identity into
the restore call (e.g., include the resolved path or a content hash in the
BackupRestoreAssessment and use that stored value instead of re-resolving) so
restoreAssessedNamedBackup uses assessment's vetted identifier (use symbols:
restoreAssessedNamedBackup, BackupRestoreAssessment,
resolveNamedBackupRestorePath, importAccounts); update tests by adding a vitest
that swaps the backup file to a different valid payload between assess and
restore (extend test/storage.test.ts around the existing tests at 2084-2168) to
assert the restore uses the original assessed content and fails if swapped.

In `@test/storage.test.ts`:
- Around line 2511-2565: Replace the fabricated Dirent hack with a real
filesystem symlink/junction: instead of mocking fs.readdir, create an actual
link file named "escaped-link.json" inside backupRoot that points to
externalBackupPath (use fs.symlink with type 'junction' on Windows) before
calling listNamedBackups, assessNamedBackupRestore("escaped-link"), and
restoreNamedBackup("escaped-link"); assert listNamedBackups returns [] and the
two restore/assess calls reject with /not a regular backup file/i, then remove
the symlink in a finally block to clean up. Ensure the test creates backupRoot
and externalBackupPath as shown, uses the same identifiers used in the diff
(backupRoot, externalBackupPath, escaped-link.json, listNamedBackups,
assessNamedBackupRestore, restoreNamedBackup), and avoids mocking filesystem
APIs so lstat/realpath containment checks run end-to-end.
- Around line 2223-2254: The test currently fabricates a Dirent with an
impossible name ("../escaped-entry.json") which conflicts with the higher-level
separator validation; change the fake entry so its name is a valid leaf (e.g.
"escaped-entry.json") and simulate an escape via resolution (make the Dirent
report isSymbolicLink() true and mock the follow-up resolution calls—e.g.,
fs.readlink or the code path that resolves the entry to an absolute path—to
return a path outside backupRoot) so assessNamedBackupRestore and
restoreNamedBackup exercise the containment checks without violating
name-validator expectations; update references in the test to the same symbols
(readdirSpy, escapedEntry, backupRoot) and keep the restore/assert logic intact.
- Around line 2112-2140: The test currently only checks deletion between assess
and restore; add a deterministic regression where the backup file is still
present but mutated to a different valid JSON between assessNamedBackupRestore()
and restoreAssessedNamedBackup() to ensure restore re-resolves and fails
appropriately. Specifically, in the same test (or a new one), after creating the
named backup via createNamedBackup("...") and calling
assessNamedBackupRestore(...) (store the returned assessment and backup.path),
overwrite the backup file at backup.path with a different valid JSON payload
(using fs.writeFileSync or the test helper used elsewhere) before calling
restoreAssessedNamedBackup(assessment), then assert that
restoreAssessedNamedBackup rejects (or returns the expected error) and that
loadAccounts() remains unchanged; reference functions createNamedBackup,
assessNamedBackupRestore, restoreAssessedNamedBackup, removeWithRetry,
saveAccounts, and loadAccounts so the change is easy to locate.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: d93ca5a2-d608-48c4-a3cb-5ff5c61b390a

📥 Commits

Reviewing files that changed from the base of the PR and between f62fa95 and 7fc3d30.

📒 Files selected for processing (2)
  • lib/storage.ts
  • test/storage.test.ts
📜 Review details
🧰 Additional context used
📓 Path-based instructions (2)
lib/**

⚙️ CodeRabbit configuration file

focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails.

Files:

  • lib/storage.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/storage.test.ts

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: 1

Caution

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

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

5830-5859: 🧹 Nitpick | 🔵 Trivial

standalone test outside main describe block.

test/storage.test.ts:5830-5859 is a standalone it block outside the main describe("storage", ...) block. this works in vitest but is slightly inconsistent with the file's organization. consider moving it inside the describe("clearAccounts edge cases", ...) block at line 5669 for consistency.

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

In `@test/storage.test.ts` around lines 5830 - 5859, The test "clearAccounts
removes discovered backup artifacts as well as fixed slots" is declared as a
standalone it block outside the main storage suite; move this it block into the
existing describe("clearAccounts edge cases", ...) block so it sits with related
tests for consistency, keeping the same test body and using the same references
to clearAccounts, getStoragePath, and the filesystem helpers; ensure
imports/setup from the surrounding describe still apply and remove the orphaned
it to avoid duplication.
♻️ Duplicate comments (5)
lib/storage.ts (2)

1919-1928: ⚠️ Potential issue | 🟠 Major

preserve the assessed backup fingerprint through import.

lib/storage.ts:1919-1928 still has a post-confirm to import race. the path is reused, but the file can still be replaced after the last assessment and before importAccounts(), so the cli may restore different contents than the user approved. test/codex-manager-cli.test.ts:2752-2820 only injects an import failure and does not cover that replace-after-confirm case. carry a stable fingerprint from assessNamedBackupRestore() and reject if it changes before import.

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.

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

In `@lib/storage.ts` around lines 1919 - 1928, The assessed backup fingerprint
produced by assessNamedBackupRestore must be preserved and verified before
import to prevent replace-after-confirm races: modify BackupRestoreAssessment to
include the stable fingerprint/hash and change restoreAssessedNamedBackup to
accept/use that fingerprint (or a new assessedBackupFingerprint field) and,
before calling importAccounts(assessment.backup.path), recompute the current
file fingerprint and throw/reject if it differs; alternatively pass the
fingerprint into importAccounts so it validates internally. Update or add vitest
cases (e.g., test/codex-manager-cli.test.ts around the earlier injection tests)
to cover the replace-after-confirm scenario and ensure the new branch handles
filesystem concurrency/EBUSY semantics per lib/** guidelines.

1998-2005: ⚠️ Potential issue | 🟠 Major

do not export the live containment fs hooks.

lib/storage.ts:1998-2005 plus lib/storage.ts:3226-3228 still hand out the mutable singleton that enforces named-backup containment. test/codex-manager-cli.test.ts:108-130 spreads the real lib/storage.ts exports into one shared vitest module instance, so one test patch can silently weaken later restore coverage. expose controlled set/reset hooks or a cloned facade instead.

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.

Also applies to: 3226-3228

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

In `@lib/storage.ts` around lines 1998 - 2005, The current export
namedBackupContainmentFs is handing out the live mutable singleton
(namedBackupContainmentFs) which tests can modify; change the module API so you
do not export the live object directly—export a factory or a function that
returns a shallow-cloned facade (e.g., getNamedBackupContainmentFs() or
createNamedBackupContainmentFs()) and add explicit
setNamedBackupContainmentFsForTest/resetNamedBackupContainmentFs hooks for
controlled test injection; update any uses that directly import
namedBackupContainmentFs to call the factory/getter, and ensure the original
internal singleton (the one referenced in namedBackupContainmentFs and the other
export sites around the same area) remains private within the module.
test/codex-manager-cli.test.ts (1)

4128-4148: ⚠️ Potential issue | 🟡 Minor

assert the rejection outcome here.

test/codex-manager-cli.test.ts:4128-4148 still passes if the ui accepts "../bad-name", swallows the collision, or exits before showing feedback. add an assertion on the user-visible rejection path—reprompt, warning text, or collision guidance—so this regression stays meaningful.

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 4128 - 4148, The test currently
only verifies runNamedBackupExportMock was called but doesn't assert the
user-visible rejection flow; update the "rejects invalid or colliding
experimental backup filenames" test to assert the rejection path by checking
that promptQuestionMock was invoked again (reprompt) or that the UI/logger was
called with a collision/invalid filename message (e.g.,
expect(consoleMock/yourLoggerMock).toHaveBeenCalledWith(expect.stringMatching(/collision|invalid|reject/i))),
referencing promptQuestionMock, runNamedBackupExportMock, runCodexMultiAuthCli,
and selectSequence to locate the test logic and mock hooks.
test/storage.test.ts (2)

2595-2649: ⚠️ Potential issue | 🟡 Minor

test still mocks Dirent instead of creating a real symlink for listing.

test/storage.test.ts:2595-2649 fabricates a Dirent with isSymbolicLink() => true rather than creating an actual symlink inside backupRoot. while tests at test/storage.test.ts:2255-2345 cover assertNamedBackupRestorePath with real symlinks, this test exercises listNamedBackups/assessNamedBackupRestore/restoreNamedBackup but doesn't hit the real lstat/realpath containment code path for symlinked entries discovered during listing.

consider creating an actual symlink file inside the backup directory pointing to externalBackupPath, then verifying that listNamedBackups filters it out and assessNamedBackupRestore("escaped-link") rejects it through the real containment checks.

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

In `@test/storage.test.ts` around lines 2595 - 2649, The test fabricates a Dirent
instead of creating a real symlink so it never exercises the real lstat/realpath
containment path; replace the Dirent mock with an actual filesystem symlink:
create backupRoot, write externalBackupPath as done, then create a symlink
inside backupRoot named "escaped-link.json" that points to externalBackupPath
(using fs.symlink/await), remove the readdir spy/mocking, call listNamedBackups
to assert the symlinked entry is filtered out and then call
assessNamedBackupRestore("escaped-link") and restoreNamedBackup("escaped-link")
to assert they reject via the real containment checks; finally remove the
symlink in the test cleanup (or use try/finally) so tests are isolated.

3413-3482: ⚠️ Potential issue | 🟠 Major

concurrent restore test is good but stale-assessment toctou remains untested.

test/storage.test.ts:3413-3482 tests concurrent restoreNamedBackup calls which re-assess internally. this validates serialization at the import layer.

however, the past review flagged a different race: two calls to assessNamedBackupRestore both return eligibleForRestore: true at MAX_ACCOUNTS - 1, then both call restoreAssessedNamedBackup with their precomputed assessments. one import changes storage, but the second restoreAssessedNamedBackup still has a stale assessment. this toctou surface on the exported restoreAssessedNamedBackup api is not pinned down here.

add a regression test that:

  1. saves accounts up to ACCOUNT_LIMITS.MAX_ACCOUNTS - 1
  2. creates a backup with 1 new account
  3. calls assessNamedBackupRestore twice concurrently to get two assessments
  4. both assessments show eligibleForRestore: true
  5. calls restoreAssessedNamedBackup(assessment1) and restoreAssessedNamedBackup(assessment2) concurrently
  6. asserts one succeeds, one fails or is a no-op, and final account count equals MAX_ACCOUNTS

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 3413 - 3482, Add a deterministic vitest
regression that reproduces the TOCTOU: save accounts up to
ACCOUNT_LIMITS.MAX_ACCOUNTS - 1, create a named backup containing one extra
account, then call assessNamedBackupRestore concurrently twice (Promise.all) to
produce two assessments and assert both report eligibleForRestore: true; next
call restoreAssessedNamedBackup concurrently with those two assessments
(Promise.allSettled) and assert exactly one fulfillment and one rejection or
no-op, and finally loadAccounts and assert restored accounts length ===
ACCOUNT_LIMITS.MAX_ACCOUNTS; reference the existing helpers used elsewhere in
the file (saveAccounts, createNamedBackup, assessNamedBackupRestore,
restoreAssessedNamedBackup, loadAccounts) and follow surrounding test style for
deterministic timings and assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/codex-manager-cli.test.ts`:
- Around line 2609-2628: Add a new test case that exercises the env-driven
non-interactive guard by setting TTYs to interactive and forcing
isNonInteractiveMode() to true: call setInteractiveTTY(true), set
isNonInteractiveModeMock.mockReturnValue(true), import and run
runCodexMultiAuthCli(["auth","restore-backup"]) and assert it returns exit code
1, that promptLoginModeMock/listNamedBackupsMock/selectMock/confirmMock are not
called, and that console.error was called with the same "Backup restore manager
requires an interactive TTY..." message; this pins the lib/codex-manager.ts
branch handling isNonInteractiveMode() while keeping tests deterministic under
Vitest.

---

Outside diff comments:
In `@test/storage.test.ts`:
- Around line 5830-5859: The test "clearAccounts removes discovered backup
artifacts as well as fixed slots" is declared as a standalone it block outside
the main storage suite; move this it block into the existing
describe("clearAccounts edge cases", ...) block so it sits with related tests
for consistency, keeping the same test body and using the same references to
clearAccounts, getStoragePath, and the filesystem helpers; ensure imports/setup
from the surrounding describe still apply and remove the orphaned it to avoid
duplication.

---

Duplicate comments:
In `@lib/storage.ts`:
- Around line 1919-1928: The assessed backup fingerprint produced by
assessNamedBackupRestore must be preserved and verified before import to prevent
replace-after-confirm races: modify BackupRestoreAssessment to include the
stable fingerprint/hash and change restoreAssessedNamedBackup to accept/use that
fingerprint (or a new assessedBackupFingerprint field) and, before calling
importAccounts(assessment.backup.path), recompute the current file fingerprint
and throw/reject if it differs; alternatively pass the fingerprint into
importAccounts so it validates internally. Update or add vitest cases (e.g.,
test/codex-manager-cli.test.ts around the earlier injection tests) to cover the
replace-after-confirm scenario and ensure the new branch handles filesystem
concurrency/EBUSY semantics per lib/** guidelines.
- Around line 1998-2005: The current export namedBackupContainmentFs is handing
out the live mutable singleton (namedBackupContainmentFs) which tests can
modify; change the module API so you do not export the live object
directly—export a factory or a function that returns a shallow-cloned facade
(e.g., getNamedBackupContainmentFs() or createNamedBackupContainmentFs()) and
add explicit setNamedBackupContainmentFsForTest/resetNamedBackupContainmentFs
hooks for controlled test injection; update any uses that directly import
namedBackupContainmentFs to call the factory/getter, and ensure the original
internal singleton (the one referenced in namedBackupContainmentFs and the other
export sites around the same area) remains private within the module.

In `@test/codex-manager-cli.test.ts`:
- Around line 4128-4148: The test currently only verifies
runNamedBackupExportMock was called but doesn't assert the user-visible
rejection flow; update the "rejects invalid or colliding experimental backup
filenames" test to assert the rejection path by checking that promptQuestionMock
was invoked again (reprompt) or that the UI/logger was called with a
collision/invalid filename message (e.g.,
expect(consoleMock/yourLoggerMock).toHaveBeenCalledWith(expect.stringMatching(/collision|invalid|reject/i))),
referencing promptQuestionMock, runNamedBackupExportMock, runCodexMultiAuthCli,
and selectSequence to locate the test logic and mock hooks.

In `@test/storage.test.ts`:
- Around line 2595-2649: The test fabricates a Dirent instead of creating a real
symlink so it never exercises the real lstat/realpath containment path; replace
the Dirent mock with an actual filesystem symlink: create backupRoot, write
externalBackupPath as done, then create a symlink inside backupRoot named
"escaped-link.json" that points to externalBackupPath (using fs.symlink/await),
remove the readdir spy/mocking, call listNamedBackups to assert the symlinked
entry is filtered out and then call assessNamedBackupRestore("escaped-link") and
restoreNamedBackup("escaped-link") to assert they reject via the real
containment checks; finally remove the symlink in the test cleanup (or use
try/finally) so tests are isolated.
- Around line 3413-3482: Add a deterministic vitest regression that reproduces
the TOCTOU: save accounts up to ACCOUNT_LIMITS.MAX_ACCOUNTS - 1, create a named
backup containing one extra account, then call assessNamedBackupRestore
concurrently twice (Promise.all) to produce two assessments and assert both
report eligibleForRestore: true; next call restoreAssessedNamedBackup
concurrently with those two assessments (Promise.allSettled) and assert exactly
one fulfillment and one rejection or no-op, and finally loadAccounts and assert
restored accounts length === ACCOUNT_LIMITS.MAX_ACCOUNTS; reference the existing
helpers used elsewhere in the file (saveAccounts, createNamedBackup,
assessNamedBackupRestore, restoreAssessedNamedBackup, loadAccounts) and follow
surrounding test style for deterministic timings and assertions.
🪄 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: 25a8fec1-534b-4359-8d39-579368ad250b

📥 Commits

Reviewing files that changed from the base of the PR and between 7fc3d30 and 58be5b5.

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

1-44: imports and setup look correct.

the new imports for named backup APIs (assessNamedBackupRestore, createNamedBackup, listNamedBackups, restoreNamedBackup, etc.) align with the feature being tested. removeWithRetry helper at test/helpers/remove-with-retry.js is a good pattern for windows cleanup reliability.


341-344: good use of removeWithRetry for windows cleanup reliability.

switching from fs.rm to removeWithRetry in afterEach handles EBUSY/EPERM transient errors that occur on windows when AV or indexers hold file handles. this makes tests more reliable cross-platform.


379-440: transaction + storage path tests cover important edge cases.

the win32 case-insensitivity test at test/storage.test.ts:407-440 is a good addition for windows path comparison behavior. the platform mock is properly restored in finally block.


478-714: import deduplication tests are thorough and verify side effects.

the changed field assertions and writeFileSpy checks ensure no unnecessary disk writes occur for no-op imports. this is important for performance and avoiding wear on SSDs. tests at test/storage.test.ts:478-714 cover edge cases like key order differences and metadata-only refreshes.


1251-1316: max accounts limit test properly validates sequential imports.

test at test/storage.test.ts:1251-1316 verifies that hitting ACCOUNT_LIMITS.MAX_ACCOUNTS (20) on first import still rejects subsequent imports that would exceed the limit. the assertion at line 1314 confirms no partial state from the failed second import.


1576-1643: chunk boundary test at test/storage.test.ts:1605-1643 is good for pagination coverage.

testing with NAMED_BACKUP_LIST_CONCURRENCY + 1 backups ensures the chunked listing logic handles boundary conditions. the test verifies all backups are returned with correct metadata.


2255-2345: symlink containment tests now use real filesystem symlinks.

tests at test/storage.test.ts:2255-2345 create actual symlinks/junctions using fs.symlink with the correct type for win32 (junction). this exercises the real lstat/realpath containment logic in lib/storage.ts rather than fabricated Dirents. this addresses the containment validation gap noted in prior review.


2169-2253: path containment tests cover both input validation and directory traversal layers.

tests at test/storage.test.ts:2169-2179 validate the separator rejection in input names, while test/storage.test.ts:2222-2253 tests the containment check when readdir returns a traversal entry. the past review concern about conflicting contracts was marked as addressed.


3216-3357: candidate cache tests are thorough and verify isolation.

test/storage.test.ts:3308-3357 testing isolated caches across concurrent listings is important for correctness. the read count assertions validate that caching behaves as expected without cross-contamination.


2651-2987: transient error retry tests cover platform differences well.

tests at test/storage.test.ts:2651-2667 verify non-windows platforms rethrow EPERM immediately, while test/storage.test.ts:2806-2856 verify win32 retries EPERM. this matches the windows-specific AV/indexer behavior patterns. the EAGAIN tests are platform-agnostic as expected.


3359-3411: concurrency limit test validates throttling behavior.

test/storage.test.ts:3359-3411 tracks peak concurrent reads to ensure NAMED_BACKUP_LIST_CONCURRENCY is respected. the delay-based tracking is a reasonable approach for this kind of concurrency verification.


1803-2002: restore eligibility tests distinguish between no-op and metadata refresh scenarios.

test/storage.test.ts:1950-2002 is a key test: backups with only metadata differences (like accessToken or lastUsed) remain eligible for restore. this allows users to refresh stale tokens from backups. the assertions verify both the assessment and the final restored state.


2084-2167: toctou tests for file changes after assessment are well designed.

test/storage.test.ts:2112-2139 is a subtle test: it mutates assessment.backup.name after assessment but before restore, verifying that restoreAssessedNamedBackup uses the validated path captured at assessment time (assessment.backup.path), not the name. this is important for security.

Comment on lines +2609 to +2628
it("returns a non-zero exit code for the direct restore-backup command when stdin/stdout are not TTYs", async () => {
setInteractiveTTY(false);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});

it("exports named pool backup from experimental settings", async () => {
try {
const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js");
const exitCode = await runCodexMultiAuthCli(["auth", "restore-backup"]);

expect(exitCode).toBe(1);
expect(promptLoginModeMock).not.toHaveBeenCalled();
expect(listNamedBackupsMock).not.toHaveBeenCalled();
expect(selectMock).not.toHaveBeenCalled();
expect(confirmMock).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith(
"Backup restore manager requires an interactive TTY. Run this command in an interactive terminal.",
);
} finally {
errorSpy.mockRestore();
}
});
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

cover the env-driven non-interactive guard.

test/codex-manager-cli.test.ts:2609-2628 only exercises the !input.isTTY || !output.isTTY branch. lib/codex-manager.ts:4228-4236 also blocks when isNonInteractiveMode() is true, so add a case with setInteractiveTTY(true) and isNonInteractiveModeMock.mockReturnValue(true) to keep that guard pinned.

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 2609 - 2628, Add a new test case
that exercises the env-driven non-interactive guard by setting TTYs to
interactive and forcing isNonInteractiveMode() to true: call
setInteractiveTTY(true), set isNonInteractiveModeMock.mockReturnValue(true),
import and run runCodexMultiAuthCli(["auth","restore-backup"]) and assert it
returns exit code 1, that
promptLoginModeMock/listNamedBackupsMock/selectMock/confirmMock are not called,
and that console.error was called with the same "Backup restore manager requires
an interactive TTY..." message; this pins the lib/codex-manager.ts branch
handling isNonInteractiveMode() while keeping tests deterministic under Vitest.

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.

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

2639-2693: ⚠️ Potential issue | 🟠 Major

replace this dirent mock with a real symlink/junction escape regression.

test/storage.test.ts:2639-2693 fabricates Dirent flags (isFile/isSymbolicLink) instead of creating an actual link on disk. that bypasses the real containment path (lib/storage.ts:2108-2192) and can miss windows junction edge cases.

possible fix
-		it("ignores symlink-like named backup entries that point outside the backups root", async () => {
+		it("rejects symlinked named backup entries that resolve outside the backups root", async () => {
 			const backupRoot = join(dirname(testStoragePath), "backups");
-			const externalBackupPath = join(testWorkDir, "outside-backup.json");
+			const outsideRoot = join(testWorkDir, "outside-backup-root");
+			const linkedRoot = join(backupRoot, "escaped-link");
+			const externalBackupPath = join(outsideRoot, "escaped-link.json");
 			await fs.mkdir(backupRoot, { recursive: true });
+			await fs.mkdir(outsideRoot, { recursive: true });
 			await fs.writeFile(
 				externalBackupPath,
 				JSON.stringify({
@@
 				"utf-8",
 			);
-
-			const originalReaddir = fs.readdir.bind(fs);
-			const readdirSpy = vi.spyOn(fs, "readdir");
-			const escapedEntry = {
-				name: "escaped-link.json",
-				isFile: () => false,
-				isSymbolicLink: () => true,
-			} as unknown as Awaited<
-				ReturnType<typeof fs.readdir>
-			>[number];
-			readdirSpy.mockImplementation(async (...args) => {
-				const [path, options] = args;
-				if (
-					String(path) === backupRoot &&
-					typeof options === "object" &&
-					options?.withFileTypes === true
-				) {
-					return [escapedEntry] as Awaited<ReturnType<typeof fs.readdir>>;
-				}
-				return originalReaddir(...(args as Parameters<typeof fs.readdir>));
-			});
+			await fs.symlink(
+				resolve(outsideRoot),
+				linkedRoot,
+				process.platform === "win32" ? "junction" : "dir",
+			);
 
 			try {
-				const backups = await listNamedBackups();
-				expect(backups).toEqual([]);
-				await expect(assessNamedBackupRestore("escaped-link")).rejects.toThrow(
+				await expect(
+					assertNamedBackupRestorePath(join(linkedRoot, "escaped-link.json"), backupRoot),
+				).rejects.toThrow(/escapes backup directory/i);
+				await expect(assessNamedBackupRestore("escaped-link")).rejects.toThrow(
 					/not a regular backup file/i,
 				);
 				await expect(restoreNamedBackup("escaped-link")).rejects.toThrow(
 					/not a regular backup file/i,
 				);
 			} finally {
-				readdirSpy.mockRestore();
+				await removeWithRetry(linkedRoot, { force: true, recursive: true });
 			}
 		});

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 2639 - 2693, The test fakes Dirent flags
instead of creating a real filesystem link, so update the test to create an
actual symlink/junction that points outside the backups root (rather than
mocking fs.readdir); create the external backup file (externalBackupPath) as
already done, then create a symlink inside backupRoot named "escaped-link.json"
that targets externalBackupPath (use fs.symlink and on Windows pass type:
"junction" when necessary), call listNamedBackups(),
assessNamedBackupRestore("escaped-link") and restoreNamedBackup("escaped-link")
to assert failures, and finally clean up by removing the symlink and external
file; remove the readdirSpy/mockImplementation and ensure teardown always runs
(try/finally) so the created symlink and file are unlinked even on test failure.
lib/storage.ts (2)

1919-1931: ⚠️ Potential issue | 🟠 Major

add content fingerprint validation before restoring an assessed backup.

lib/storage.ts:1919-1931 revalidates containment, but it still allows a same-path file swap after assessment. that means restoreAssessedNamedBackup() can import content different from what was assessed. this is a residual assessment→restore toctou risk. please also pin this with a regression around test/storage.test.ts:2112-2139 (swap file contents between assess and restore-assessed).

possible fix
 export async function restoreAssessedNamedBackup(
 	assessment: Pick<BackupRestoreAssessment, "backup" | "eligibleForRestore" | "error">,
 ): Promise<ImportAccountsResult> {
 	if (!assessment.eligibleForRestore) {
 		throw new Error(
 			assessment.error ?? "Backup is not eligible for restore.",
 		);
 	}
 	const backupRoot = getNamedBackupRoot(getStoragePath());
 	const resolvedPath = await retryTransientNamedBackupPathValidation(() =>
 		assertNamedBackupRestorePath(assessment.backup.path, backupRoot),
 	);
+	const currentStats = await retryTransientFilesystemOperation(() =>
+		fs.stat(resolvedPath),
+	);
+	if (
+		(typeof assessment.backup.updatedAt === "number" &&
+			Number.isFinite(assessment.backup.updatedAt) &&
+			currentStats.mtimeMs !== assessment.backup.updatedAt) ||
+		(typeof assessment.backup.sizeBytes === "number" &&
+			currentStats.size !== assessment.backup.sizeBytes)
+	) {
+		throw new Error("Backup changed after assessment. Re-assess before restoring.");
+	}
 	return importAccounts(resolvedPath);
 }

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.

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

In `@lib/storage.ts` around lines 1919 - 1931, The assessed backup restore
currently only revalidates containment and is vulnerable to TOCTOU swaps; update
the flow so the assessment includes a content fingerprint (e.g., SHA256) stored
on the BackupRestoreAssessment record and then have restoreAssessedNamedBackup
verify that fingerprint before importing: compute the fingerprint during
assessment (where BackupRestoreAssessment is created), persist it on the
assessment (e.g., assessment.backup.fingerprint), and in
restoreAssessedNamedBackup (alongside assertNamedBackupRestorePath /
retryTransientNamedBackupPathValidation) recompute the fingerprint for the
resolvedPath and reject if it differs from the assessment fingerprint, then call
importAccounts only after the match; also add a regression test (based on
test/storage.test.ts:2112-2139) that swaps file contents between assess and
restore-assessed to ensure detection, and ensure the change respects Windows
IO/concurrency (EBUSY/429) and any auth-rotation concerns per lib/** guidelines.

3225-3227: ⚠️ Potential issue | 🟠 Major

do not export the live containment fs singleton.

lib/storage.ts:3225-3227 exposes a mutable process-wide object (__testOnly.namedBackupContainmentFs). any caller can monkey-patch lstat/realpath and weaken containment checks for later restores in the same process.

possible fix
-const namedBackupContainmentFs = {
+type NamedBackupContainmentFs = {
+	lstat(path: string): Promise<Awaited<ReturnType<typeof fs.lstat>>>;
+	realpath(path: string): Promise<string>;
+};
+
+function createNamedBackupContainmentFs(): NamedBackupContainmentFs {
+	return {
+		lstat(path: string) {
+			return fs.lstat(path);
+		},
+		realpath(path: string) {
+			return fs.realpath(path);
+		},
+	};
+}
+
+let namedBackupContainmentFs = createNamedBackupContainmentFs();
-	lstat(path: string) {
-		return fs.lstat(path);
-	},
-	realpath(path: string) {
-		return fs.realpath(path);
-	},
-};
@@
 export const __testOnly = {
-	namedBackupContainmentFs,
+	setNamedBackupContainmentFs(overrides: Partial<NamedBackupContainmentFs>) {
+		namedBackupContainmentFs = { ...namedBackupContainmentFs, ...overrides };
+	},
+	resetNamedBackupContainmentFs() {
+		namedBackupContainmentFs = createNamedBackupContainmentFs();
+	},
 };

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.

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

In `@lib/storage.ts` around lines 3225 - 3227, The export __testOnly exposes the
live singleton namedBackupContainmentFs which allows callers to monkey-patch
lstat/realpath and weaken containment checks; change the export so tests get a
safe handle instead of the mutable singleton — e.g., remove exporting the raw
namedBackupContainmentFs and provide either a factory/getter that returns a
fresh instance or a read-only wrapper/API (e.g., getTestContainmentFs(),
createNamedBackupContainmentFs(), or an immutableProxy) that does not expose
mutating methods or underlying functions; update references to use the new
accessor and ensure lstat/realpath cannot be overridden via the exported symbol.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@lib/storage.ts`:
- Around line 1919-1931: The assessed backup restore currently only revalidates
containment and is vulnerable to TOCTOU swaps; update the flow so the assessment
includes a content fingerprint (e.g., SHA256) stored on the
BackupRestoreAssessment record and then have restoreAssessedNamedBackup verify
that fingerprint before importing: compute the fingerprint during assessment
(where BackupRestoreAssessment is created), persist it on the assessment (e.g.,
assessment.backup.fingerprint), and in restoreAssessedNamedBackup (alongside
assertNamedBackupRestorePath / retryTransientNamedBackupPathValidation)
recompute the fingerprint for the resolvedPath and reject if it differs from the
assessment fingerprint, then call importAccounts only after the match; also add
a regression test (based on test/storage.test.ts:2112-2139) that swaps file
contents between assess and restore-assessed to ensure detection, and ensure the
change respects Windows IO/concurrency (EBUSY/429) and any auth-rotation
concerns per lib/** guidelines.
- Around line 3225-3227: The export __testOnly exposes the live singleton
namedBackupContainmentFs which allows callers to monkey-patch lstat/realpath and
weaken containment checks; change the export so tests get a safe handle instead
of the mutable singleton — e.g., remove exporting the raw
namedBackupContainmentFs and provide either a factory/getter that returns a
fresh instance or a read-only wrapper/API (e.g., getTestContainmentFs(),
createNamedBackupContainmentFs(), or an immutableProxy) that does not expose
mutating methods or underlying functions; update references to use the new
accessor and ensure lstat/realpath cannot be overridden via the exported symbol.

In `@test/storage.test.ts`:
- Around line 2639-2693: The test fakes Dirent flags instead of creating a real
filesystem link, so update the test to create an actual symlink/junction that
points outside the backups root (rather than mocking fs.readdir); create the
external backup file (externalBackupPath) as already done, then create a symlink
inside backupRoot named "escaped-link.json" that targets externalBackupPath (use
fs.symlink and on Windows pass type: "junction" when necessary), call
listNamedBackups(), assessNamedBackupRestore("escaped-link") and
restoreNamedBackup("escaped-link") to assert failures, and finally clean up by
removing the symlink and external file; remove the readdirSpy/mockImplementation
and ensure teardown always runs (try/finally) so the created symlink and file
are unlinked even on test failure.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: d5c52290-5447-4ad0-8980-d01ec97961bf

📥 Commits

Reviewing files that changed from the base of the PR and between 58be5b5 and eb64389.

📒 Files selected for processing (2)
  • lib/storage.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/storage.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/storage.test.ts

@ndycode ndycode changed the base branch from fresh/01-reset-safety 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