Skip to content

feat(auth): add restore preview#112

Open
ndycode wants to merge 16 commits intodevfrom
fresh/13-restore-preview
Open

feat(auth): add restore preview#112
ndycode wants to merge 16 commits intodevfrom
fresh/13-restore-preview

Conversation

@ndycode
Copy link
Owner

@ndycode ndycode commented Mar 16, 2026

Summary

  • Reopens the preserved restore-preview slice from archived PR #86
  • Stacked PR on fresh/12-backup-browser

What Changed

  • Adds restore assessment preview before apply, including refreshed pre-confirm assessment and replace-only restore support.
  • Keeps the branch focused to restore-preview behavior and the related 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 17cec7e and 5ddee4d

Additional Notes

  • Preserved branch validation from the fresh-restack run passed lint, typecheck, build, and full tests.

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 ports the restore-preview slice from archived PR #86 onto the fresh/12-backup-browser base, wiring up runBackupRestorePreview, assessNamedBackupRestoreCandidate, importNormalizedAccounts, and the backup browser manager into a cohesive replace-only + merge restore flow. the destructive-actions.ts extraction and sync-history.ts addition are clean, and the assessment type model is thorough. three issues need attention before merge:

  • replace-only result message never firesformatNamedBackupRestoreResult guards the "Replaced N account(s)" branch with result.skipped === replacedCount, a condition that evaluates to false for the standard single-account replacement (skipped = 0, replacedCount = 1). users always see "Imported 0 accounts. Skipped 0." instead. the test at line 680–682 masks this by mocking restoreNamedBackup with skipped: 1, a value the real importNormalizedAccounts never produces for that scenario. fix: drop the result.skipped === replacedCount guard.
  • loadBackupBrowserEntries double-reads every backup on the non-fast pathlistNamedBackups() loads all backup files via scanNamedBackups, then assessNamedBackupRestore loads each one again. on windows this doubles the EBUSY exposure window per file. getActionableNamedBackupRestores avoids this by reusing the scanned candidates; loadBackupBrowserEntries should do the same.
  • clearFlaggedAccounts keeps the reset-intent marker alive when any backup file fails to delete — if a .bak auxiliary file gets an EPERM the main flagged file may already be gone, but hadError = true skips marker cleanup. on next startup the lingering marker can suppress WAL replay or trigger spurious recovery prompts. the marker should be cleaned up whenever the primary path deletion succeeded.

Confidence Score: 3/5

  • hold for the replace-only message bug and the double-read before merging; no data-loss risk but the user-visible restore confirmation message is silently wrong in the core scenario.
  • the assessment model, transaction safety, and destructive-action extraction are solid. the three flagged issues are not data-loss bugs, but one (formatNamedBackupRestoreResult) means the primary new user-facing feature (replace-only result message) never renders correctly, and the test suite doesn't catch it due to incorrect mock values. the windows double-read and marker-cleanup gaps are lower severity but worth fixing before promoting to stable.
  • lib/codex-manager.ts (formatNamedBackupRestoreResult condition, loadBackupBrowserEntries double-read) and lib/storage.ts (clearFlaggedAccounts marker gating)

Important Files Changed

Filename Overview
lib/storage.ts core of the PR — adds ~620 lines of backup restore assessment, preview types, named backup scan/list, and importNormalizedAccounts. reset-marker gating bug in clearFlaggedAccounts and pre-existing double-read concerns flagged.
lib/codex-manager.ts runBackupRestorePreview and runBackupBrowserManager are the main new flows. formatNamedBackupRestoreResult has a logic bug in the replace-only branch; loadBackupBrowserEntries reads each backup file twice on the non-fast path (EBUSY risk on windows).
lib/destructive-actions.ts new file extracting deleteAccountAtIndex, deleteSavedAccounts, and resetLocalState from codex-manager.ts. logic is clean; rebaseActiveIndicesAfterDelete correctly shifts family indices; rollback on flagged-save failure is thorough.
lib/sync-history.ts new file adding ndjson sync-history with a mutex, MAX_HISTORY_ENTRIES cap, and separate latest-snapshot file. retryable remove codes include ENOTEMPTY for windows junction cleanup.
test/codex-manager-cli.test.ts replace-only restore test (line 558) mocks restoreNamedBackup returning skipped: 1 — a value the real function never produces for that scenario — which masks the formatNamedBackupRestoreResult condition bug.
test/recovery.test.ts large expansion covering assessment, limit races, preview conflicts, and active-account outcome tracking. good breadth; missing a real (non-mocked) end-to-end path through importNormalizedAccountsformatNamedBackupRestoreResult for replace-only.
test/destructive-actions.test.ts new 301-line test covering delete, reset, and rollback paths for destructive-actions.ts. thorough including flagged-save rollback scenario.
lib/ui/copy.ts UI copy strings updated — removeAllAccounts label corrected to "Delete Saved Accounts", "Restore From Backup" added, settings section hints added. no logic.
lib/cli.ts adds restore-backup menu entry and isInteractiveLoginMenuAvailable helper; allows empty-storage login menu when backups exist. looks correct.

Sequence Diagram

sequenceDiagram
    participant User
    participant CLI as runBackupBrowserManager
    participant Preview as runBackupRestorePreview
    participant Storage as storage.ts
    participant FS as Filesystem

    User->>CLI: codex auth restore-backup
    CLI->>Storage: listNamedBackups()
    Storage->>FS: readdir(backupRoot)
    Storage->>FS: readFile(backup.json) ×N [scanNamedBackups]
    Storage-->>CLI: NamedBackupMetadata[]

    CLI->>Storage: assessNamedBackupRestore(name) ×N
    Storage->>FS: readFile(backup.json) ×N [double-read]
    Storage->>Storage: loadAccounts()
    Storage-->>CLI: BackupRestoreAssessment[]

    User->>CLI: select backup → preview-restore
    CLI->>Preview: runBackupRestorePreview(entry)
    Preview->>Storage: assessNamedBackupRestore(name) [re-assess]
    Storage->>FS: readFile(backup.json)
    Storage-->>Preview: BackupRestoreAssessment

    Preview->>User: show preview + confirm prompt
    User->>Preview: confirm = true

    Preview->>Storage: restoreNamedBackup(name, {assessment})
    Storage->>FS: readFile(backup.json) [loadImportableBackupCandidate]
    Storage->>Storage: withAccountStorageTransaction
    Storage->>Storage: deduplicateAccounts([existing, backup])
    Storage->>FS: writeFile(accounts.json)
    Storage-->>Preview: {imported, skipped, total}

    Preview->>User: formatNamedBackupRestoreResult(result, assessment)
Loading

Comments Outside Diff (3)

  1. test/storage-recovery-paths.test.ts, line 1182 (link)

    Weakened assertion no longer verifies the expected winner

    the assertion was narrowed from toBe(manual-meta-checkpoint) to not.toBe(.cache). this only confirms the wrong file isn't picked; it doesn't confirm the right file is. if the path resolution logic regresses and picks some third unexpected file, this test will silently pass.

    the setTimeout(resolve, 20) delay addresses the mtime race — that's fine — but the assertion should still pin the correct winner:

    if this is still flaky after the mtime nudge, consider bumping the delay or using a deterministic mtime offset rather than weakening the contract.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: test/storage-recovery-paths.test.ts
    Line: 1182
    
    Comment:
    **Weakened assertion no longer verifies the expected winner**
    
    the assertion was narrowed from `toBe(manual-meta-checkpoint)` to `not.toBe(.cache)`. this only confirms the _wrong_ file isn't picked; it doesn't confirm the _right_ file is. if the path resolution logic regresses and picks some third unexpected file, this test will silently pass.
    
    the `setTimeout(resolve, 20)` delay addresses the mtime race — that's fine — but the assertion should still pin the correct winner:
    
    
    
    if this is still flaky after the mtime nudge, consider bumping the delay or using a deterministic mtime offset rather than weakening the contract.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Codex

  2. lib/codex-manager.ts, line 752-756 (link)

    P1 result.skipped === replacedCount condition never fires for standard replace-only restores

    for the typical single-account replace-only scenario (backup = [A'(new_token)], existing = [A(old_token)]), importNormalizedAccounts computes:

    skipped = max(0, backup_count - imported - options.replacedExistingCount)
            = max(0, 1 - 0 - 1) = 0
    

    so result.skipped === replacedCount resolves to 0 === 1false, and the fallback message "Imported 0 accounts. Skipped 0. Total accounts: 1." is shown instead of the intended "Replaced 1 current account.". the condition only becomes truthy when backup_count === 2 × replacedCount (i.e. exactly half the backup entries are within-backup duplicates), which is an obscure edge case.

    the test at line 680–682 masks this by mocking restoreNamedBackup to return { imported: 0, skipped: 1, total: 1 } — a value the real function never produces for this scenario.

    the condition should just be:

    also needs a companion vitest that calls the real importNormalizedAccounts (not mocked) and passes the output to formatNamedBackupRestoreResult to guard against a recurrence.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: lib/codex-manager.ts
    Line: 752-756
    
    Comment:
    **`result.skipped === replacedCount` condition never fires for standard replace-only restores**
    
    for the typical single-account replace-only scenario (`backup = [A'(new_token)]`, `existing = [A(old_token)]`), `importNormalizedAccounts` computes:
    
    ```
    skipped = max(0, backup_count - imported - options.replacedExistingCount)
            = max(0, 1 - 0 - 1) = 0
    ```
    
    so `result.skipped === replacedCount` resolves to `0 === 1``false`, and the fallback message `"Imported 0 accounts. Skipped 0. Total accounts: 1."` is shown instead of the intended `"Replaced 1 current account."`. the condition only becomes truthy when `backup_count === 2 × replacedCount` (i.e. exactly half the backup entries are within-backup duplicates), which is an obscure edge case.
    
    the test at line 680–682 masks this by mocking `restoreNamedBackup` to return `{ imported: 0, skipped: 1, total: 1 }` — a value the real function never produces for this scenario.
    
    the condition should just be:
    
    
    
    also needs a companion vitest that calls the real `importNormalizedAccounts` (not mocked) and passes the output to `formatNamedBackupRestoreResult` to guard against a recurrence.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Codex

  3. lib/codex-manager.ts, line 1002-1035 (link)

    P1 loadBackupBrowserEntries double-reads every backup file on the non-fast path — widens EBUSY window on windows

    when startupAssessments is not provided, the function first calls listNamedBackups()scanNamedBackups()loadBackupCandidate(path) for every backup, then immediately calls assessNamedBackupRestore(backup.name, ...) which calls loadBackupCandidate(path) again for every backup. each backup JSON is therefore read twice from disk in rapid succession. on windows, an antivirus or shadow-copy lock between the two reads converts a valid scan into an EBUSY failure at assessment time, returning assessmentError entries in the browser UI that were fine a millisecond earlier.

    getActionableNamedBackupRestores avoids this by reusing the NamedBackupScanEntry data from the scan. loadBackupBrowserEntries should do the same — call scanNamedBackups() directly and pass the already-loaded candidates to assessNamedBackupRestoreCandidate instead of fetching them twice:

    const scanResult = await scanNamedBackups();
    namedEntries = await Promise.all(
        scanResult.backups.map(async ({ backup, candidate }) => {
            try {
                const assessment = assessNamedBackupRestoreCandidate(backup, candidate, currentStorage);
                return { kind: "named" as const, label: backup.name, backup, assessment };
            } catch (error) { ... }
        }),
    );

    no additional vitest for the double-read race exists in the new tests; worth adding a test that stubs fs.readFile to fail on the second call per path and verifies graceful degradation.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: lib/codex-manager.ts
    Line: 1002-1035
    
    Comment:
    **`loadBackupBrowserEntries` double-reads every backup file on the non-fast path — widens EBUSY window on windows**
    
    when `startupAssessments` is not provided, the function first calls `listNamedBackups()``scanNamedBackups()``loadBackupCandidate(path)` for every backup, then immediately calls `assessNamedBackupRestore(backup.name, ...)` which calls `loadBackupCandidate(path)` again for every backup. each backup JSON is therefore read twice from disk in rapid succession. on windows, an antivirus or shadow-copy lock between the two reads converts a valid scan into an EBUSY failure at assessment time, returning `assessmentError` entries in the browser UI that were fine a millisecond earlier.
    
    `getActionableNamedBackupRestores` avoids this by reusing the `NamedBackupScanEntry` data from the scan. `loadBackupBrowserEntries` should do the same — call `scanNamedBackups()` directly and pass the already-loaded candidates to `assessNamedBackupRestoreCandidate` instead of fetching them twice:
    
    ```ts
    const scanResult = await scanNamedBackups();
    namedEntries = await Promise.all(
        scanResult.backups.map(async ({ backup, candidate }) => {
            try {
                const assessment = assessNamedBackupRestoreCandidate(backup, candidate, currentStorage);
                return { kind: "named" as const, label: backup.name, backup, assessment };
            } catch (error) { ... }
        }),
    );
    ```
    
    no additional vitest for the double-read race exists in the new tests; worth adding a test that stubs `fs.readFile` to fail on the second call per path and verifies graceful degradation.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Codex

Fix All in Codex

Prompt To Fix All With AI
This is a comment left during a code review.
Path: lib/codex-manager.ts
Line: 752-756

Comment:
**`result.skipped === replacedCount` condition never fires for standard replace-only restores**

for the typical single-account replace-only scenario (`backup = [A'(new_token)]`, `existing = [A(old_token)]`), `importNormalizedAccounts` computes:

```
skipped = max(0, backup_count - imported - options.replacedExistingCount)
        = max(0, 1 - 0 - 1) = 0
```

so `result.skipped === replacedCount` resolves to `0 === 1``false`, and the fallback message `"Imported 0 accounts. Skipped 0. Total accounts: 1."` is shown instead of the intended `"Replaced 1 current account."`. the condition only becomes truthy when `backup_count === 2 × replacedCount` (i.e. exactly half the backup entries are within-backup duplicates), which is an obscure edge case.

the test at line 680–682 masks this by mocking `restoreNamedBackup` to return `{ imported: 0, skipped: 1, total: 1 }` — a value the real function never produces for this scenario.

the condition should just be:

```suggestion
	if (result.imported === 0 && replacedCount > 0) {
		return `Replaced ${replacedCount} current account${replacedCount === 1 ? "" : "s"}. Total accounts: ${result.total}.`;
	}
	return `Imported ${result.imported} account${result.imported === 1 ? "" : "s"}. Skipped ${result.skipped}. Total accounts: ${result.total}.`;
```

also needs a companion vitest that calls the real `importNormalizedAccounts` (not mocked) and passes the output to `formatNamedBackupRestoreResult` to guard against a recurrence.

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: 1002-1035

Comment:
**`loadBackupBrowserEntries` double-reads every backup file on the non-fast path — widens EBUSY window on windows**

when `startupAssessments` is not provided, the function first calls `listNamedBackups()``scanNamedBackups()``loadBackupCandidate(path)` for every backup, then immediately calls `assessNamedBackupRestore(backup.name, ...)` which calls `loadBackupCandidate(path)` again for every backup. each backup JSON is therefore read twice from disk in rapid succession. on windows, an antivirus or shadow-copy lock between the two reads converts a valid scan into an EBUSY failure at assessment time, returning `assessmentError` entries in the browser UI that were fine a millisecond earlier.

`getActionableNamedBackupRestores` avoids this by reusing the `NamedBackupScanEntry` data from the scan. `loadBackupBrowserEntries` should do the same — call `scanNamedBackups()` directly and pass the already-loaded candidates to `assessNamedBackupRestoreCandidate` instead of fetching them twice:

```ts
const scanResult = await scanNamedBackups();
namedEntries = await Promise.all(
    scanResult.backups.map(async ({ backup, candidate }) => {
        try {
            const assessment = assessNamedBackupRestoreCandidate(backup, candidate, currentStorage);
            return { kind: "named" as const, label: backup.name, backup, assessment };
        } catch (error) { ... }
    }),
);
```

no additional vitest for the double-read race exists in the new tests; worth adding a test that stubs `fs.readFile` to fail on the second call per path and verifies graceful degradation.

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: 3511-3540

Comment:
**reset marker kept alive when only a backup file fails to delete — false-positive intent signal on next startup**

old `clearFlaggedAccounts` deleted `markerPath` inside the same loop as `[path, ...backupPaths]`; if a backup delete failed it continued and still cleaned up the marker. new code gates marker deletion on `!hadError`, so a single EBUSY/EPERM on any `.bak` file leaves the reset intent marker on disk even though the primary flagged-accounts file was successfully removed.

on the next startup the lingering marker may suppress WAL replay or trigger "reset in progress" recovery logic for flagged accounts that are actually clean, which silently discards valid recovery state. the same pattern was applied in `clearAccounts` (the `hadError` + `unlinkWithRetry` refactor is good), but flagged accounts are a distinct concern because their reset marker is separate.

consider deleting the marker unconditionally if the primary `path` delete succeeded, regardless of backup cleanup failures:

```ts
let primaryCleared = false;
for (const candidate of [path, ...backupPaths]) { ... }

// always attempt marker cleanup if main file is gone
if (primaryCleared || !hadError) {
    try { await unlinkWithRetry(markerPath); } catch { ... }
}
```

no vitest covers the scenario where `unlinkWithRetry` succeeds on `path` but fails on a backup path and the marker state is verified.

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

Last reviewed commit: "test(restore): tight..."

Greptile also left 1 inline comment on this PR.

Copilot AI review requested due to automatic review settings March 16, 2026 08:52
@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

📝 Walkthrough

walkthrough

adds a preview-restore workflow for named backups, new assessment and preview types, formatting helpers, and refactors restore flows to use assessment-driven previews and gated restores. changes span manager UI, storage assessment/loading, and tests.

changes

Cohort / File(s) Summary
manager preview-restore flow
lib/codex-manager.ts
adds BackupDetailAction (`"back"
storage assessment & preview interfaces
lib/storage.ts
introduces rich types for named/rotating backups and previews (NamedBackupMetadata, RotatingBackupMetadata, BackupRestoreAccountPreview, BackupRestoreConflictDetail, BackupRestoreActiveAccountPreview, BackupRestoreConflictPreview, NamedBackupRestorePreview, BackupRestoreAssessment, etc.). adds many helpers for scanning/loading/assessing backups, assessment-driven restoreNamedBackup(name, options?) (now accepts { assessment? }), assessNamedBackupRestore, listing APIs, and robust I/O/retry utilities.
manager test coverage
test/codex-manager-cli.test.ts
updates tests to expect restoreNamedBackup calls with an options object containing assessment. adds/updates tests for preview-restore flow, reassessment during preview, confirmation prompts, and subsequent restore invocation with assessment context.
storage restore flow tests
test/storage.test.ts
adds extensive tests exercising named backups, rotating backups, assessment behavior, restore previews, retry semantics (EPERM/EBUSY/EAGAIN), WAL/cleanup paths, and public API surface (assessNamedBackupRestore, listNamedBackups, listRotatingBackups, restoreNamedBackup).
recovery path timestamp updates
test/storage-recovery-paths.test.ts
adds utimes manipulation to simulate cache and manual backup mtimes to ensure newest valid manual backup selection. minor assertion formatting changes.

sequence diagram

sequenceDiagram
    actor user as User
    participant cli as CLI/Menu
    participant mgr as codex-manager
    participant assess as assess (storage)
    participant store as storage

    user->>cli: browse backups
    cli->>mgr: showBackupBrowserDetails(entry)
    mgr->>store: read backup metadata / candidate
    store-->>mgr: candidate metadata
    mgr-->>cli: action: preview-restore
    cli->>user: prompt preview
    user->>mgr: runBackupRestorePreview(entry)
    mgr->>assess: assessNamedBackupRestoreCandidate(name)
    assess->>store: load rawAccounts, read current storage
    store-->>assess: conflicts, active-account preview
    assess-->>mgr: BackupRestoreAssessment
    mgr->>mgr: buildRestoreAssessmentLines(assessment)
    mgr-->>cli: display preview + assessment
    cli->>user: confirm restore?
    user->>mgr: confirm
    mgr->>store: restoreNamedBackup(name, { assessment })
    store->>store: importNormalizedAccounts with assessment
    store-->>mgr: { imported, skipped, total }
    mgr->>mgr: formatNamedBackupRestoreResult(...)
    mgr-->>cli: display result
Loading

estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

review notes

missing regression tests: tests do not cover a stale-assessment case where assessment computed during preview becomes invalid before restore. add a test that mutates current storage between runBackupRestorePreview() and restoreNamedBackup() to ensure either re-validation occurs or restore fails safely. cite: test/codex-manager-cli.test.ts:line ~n/a, lib/storage.ts:line ~n/a.

windows edge case: test/storage-recovery-paths.test.ts:line ~n/a uses utimes to manipulate file mtimes. verify behavior on windows filesystems (precision, timezone, wsl). ensure selection logic in listNamedBackups / recovery path tolerates filesystem timestamp resolution differences. cite: test/storage-recovery-paths.test.ts:line ~n/a, lib/storage.ts:line ~n/a.

concurrency risk: current flow re-assesses in runBackupRestorePreview then passes assessment into restoreNamedBackup without guaranteed re-validation at import time. another process can add accounts or change backup files during user confirmation window, producing a stale assessment and unexpected import results. recommend adding a final validation/reconciliation inside restoreNamedBackup or explicit staleness detection. cite: lib/codex-manager.ts:line ~n/a, lib/storage.ts:line ~n/a.

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.45% 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 is comprehensive with clear objectives, validation checkmarks, risk assessment, and rollback plan. All required sections present except minor docs gaps noted. Docs checklist items are unchecked despite user-visible behavior changes (restore preview, replace-only messaging). Mark as skipped or verify docs/features.md and docs/reference/* cover new restore assessment flow.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed Title 'feat(auth): add restore preview' follows conventional commits format with valid type (feat), scope (auth), and clear lowercase imperative summary under 72 characters.

✏️ 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/13-restore-preview
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch fresh/13-restore-preview
📝 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 “restore preview” flow for named backup restores, including refreshed assessment at confirmation time and support for replace-only restores, with expanded assessment/preview metadata surfaced through the CLI and validated via new storage/CLI tests.

Changes:

  • Extends backup restore assessment with account/conflict/active-account preview fields and uses them to gate restore eligibility.
  • Adds an interactive CLI “Preview Restore” action that re-assesses just-in-time and confirms with a detailed summary.
  • Updates/adds tests covering replace-only restores, limit race reassessment, and new preview messaging.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
lib/storage.ts Adds richer restore assessment/preview fields, recalculates merge/replace stats, and adds eligibility checks to restoreNamedBackup.
lib/codex-manager.ts Implements the restore preview UI, assessment summary formatting, and confirmation messaging updates.
test/storage.test.ts Adds coverage for preview fields, replace-only restore conflict details, and reassessment when limits change.
test/storage-recovery-paths.test.ts Adjusts recovery-path test timing and loosens an assertion around latestValidPath.
test/codex-manager-cli.test.ts Adds/updates CLI tests to validate preview flow, refreshed assessment confirmation, and failure handling.

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

}
| { type: "back" };

type BackupDetailAction = "back" | "preview-restore" | "restore";
Comment on lines +4662 to 4666
const action = await select<BackupDetailAction>(
[
{ label: "Restore This Backup", value: "restore", color: "green" },
{ label: "Back", value: "back" },
{ label: "Preview Restore", value: "preview-restore", color: "green" },
],
const parts: string[] = [];
if (replacements > 0) {
parts.push(
`${replacements} replace current${replacements === 1 ? "" : " accounts"}`,
Comment on lines 4663 to +4670
[
{ label: "Restore This Backup", value: "restore", color: "green" },
{ label: "Back", value: "back" },
{ label: "Preview Restore", value: "preview-restore", color: "green" },
],
{
message: "Backup Browser",
message: "Backup Actions",
subtitle: entry.label,
help: "Enter Select | Q Back",
help: "Enter Preview | Q Back",
lib/storage.ts Outdated
Comment on lines 2446 to 2453
name: string,
): Promise<{ imported: number; total: number; skipped: number }> {
const backupPath = await resolveNamedBackupRestorePath(name);
const assessment = await assessNamedBackupRestore(name);
if (!assessment.eligibleForRestore || assessment.wouldExceedLimit) {
throw new Error(assessment.error ?? "Backup is not eligible for restore");
}
return importAccounts(backupPath);
const backupPath = await resolveNamedBackupRestorePath(name);
const assessment = await assessNamedBackupRestore(name);
if (!assessment.eligibleForRestore || assessment.wouldExceedLimit) {
throw new Error(assessment.error ?? "Backup is not eligible for restore");
);

await fs.writeFile(`${storagePath}.cache`, "noise", "utf-8");
await new Promise((resolve) => setTimeout(resolve, 20));
const cacheEntries = accountSnapshots.filter((snapshot) => snapshot.path.endsWith(".cache"));
expect(cacheEntries).toHaveLength(0);
expect(metadata.accounts.latestValidPath).toBe(`${storagePath}.manual-meta-checkpoint`);
expect(metadata.accounts.latestValidPath).not.toBe(`${storagePath}.cache`);
Comment on lines +2073 to 2075
if (assessment.eligibleForRestore) {
actionable.push(assessment);
}
Copy link

Choose a reason for hiding this comment

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

P1 getActionableNamedBackupRestores filter broadened — verify startup-restore callers handle imported === 0

the filter changed from:

assessment.eligibleForRestore &&
!assessment.wouldExceedLimit &&
assessment.imported !== null &&
assessment.imported > 0

to just assessment.eligibleForRestore, which now includes replace-only restores (imported === 0, replacedExistingCount > 0).

the browser and preview paths were updated, but the startup auto-restore flow (which surfaces prompts when the user has no accounts or after a crash) may display restore hints where 0 accounts would be added — only existing tokens replaced. callers that previously formatted messages as "Importing N accounts…" without checking replacedExistingCount could silently show "Import 0 accounts" to users. trace all consumers of assessments from this function to confirm each surfaces the replace-only message from formatNamedBackupRestoreResult, not an inline string.

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

Comment:
**`getActionableNamedBackupRestores` filter broadened — verify startup-restore callers handle `imported === 0`**

the filter changed from:
```ts
assessment.eligibleForRestore &&
!assessment.wouldExceedLimit &&
assessment.imported !== null &&
assessment.imported > 0
```
to just `assessment.eligibleForRestore`, which now includes replace-only restores (`imported === 0, replacedExistingCount > 0`).

the browser and preview paths were updated, but the startup auto-restore flow (which surfaces prompts when the user has no accounts or after a crash) may display restore hints where 0 accounts would be added — only existing tokens replaced. callers that previously formatted messages as `"Importing N accounts…"` without checking `replacedExistingCount` could silently show `"Import 0 accounts"` to users. trace all consumers of `assessments` from this function to confirm each surfaces the replace-only message from `formatNamedBackupRestoreResult`, not an inline string.

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

Fix in Codex

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

🤖 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 2526-2563: importNormalizedAccounts currently computes skipped as
normalized.accounts.length - imported which treats replaced accounts as skipped;
instead compute how many of the incoming normalized.accounts were actually
applied (either added or replaced) and set skipped = normalized.accounts.length
- appliedCount. Concretely, after producing deduplicatedAccounts, build a lookup
(by account.id or your canonical account key) for existingAccounts and for
deduplicatedAccounts, then compute appliedCount = count of entries in
normalized.accounts that appear in deduplicatedAccounts (regardless of whether
they replaced an existing one); set imported to the number of new additions
(deduplicatedAccounts.length - existingAccounts.length) if you still need that
metric, but compute skipped from appliedCount so replaced items are not flagged
as skipped; update the return to use these computed values (references:
importNormalizedAccounts, existingAccounts, normalized.accounts,
deduplicatedAccounts, deduplicateAccounts).
- Around line 2429-2447: restoreNamedBackup currently always calls
loadImportableBackupCandidate, re-opening the backup even when the caller
already has the candidate, and importNormalizedAccounts (or the restore counting
logic) treats replacements as "skipped", causing preview/restore semantic drift.
Change restoreNamedBackup(name, options) to accept and use an optional preloaded
candidate (e.g., options.candidate) and only call loadImportableBackupCandidate
when that candidate is not provided; keep the existing
buildNamedBackupMetadata/assessNamedBackupRestoreCandidate flow but pass the
supplied candidate into assessment to avoid re-reading the file. Also update the
account import/counting logic in importNormalizedAccounts (the code that
increments skipped/replacements) so that only accounts actually dropped due to
dedup are counted as skipped and replacements are not counted as skipped,
ensuring assessment.skipped matches restore result.

In `@test/codex-manager-cli.test.ts`:
- Around line 927-930: The restoreNamedBackupMock assertion is too loose—replace
the expect.objectContaining({ assessment: expect.anything() }) check with a
deterministic assertion that the passed assessment equals the expected/refreshed
assessment used earlier in the test; locate the call to restoreNamedBackupMock
in this file (and similar occurrences like at lines ~985, 2231, 2623, 3734,
3782, 3912, 4034, 4225) and assert using a deep equality matcher (e.g.,
expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup",
expect.objectContaining({ assessment: expectedAssessment })) or the exact
object) where expectedAssessment is the known/refreshed assessment variable
constructed in the test (or extracted right before invocation) so the test fails
if a stale assessment is passed.

In `@test/storage-recovery-paths.test.ts`:
- Around line 783-786: The test is setting newerManualMtime in the future which
can cause flakes on some filesystems; change the mtime values so both are in the
past (e.g., keep olderManualMtime = new Date(Date.now() - 5000) and set
newerManualMtime to a slightly more recent past time like new Date(Date.now() -
1000)) and then call fs.utimes(olderManualPath, olderManualMtime,
olderManualMtime) and fs.utimes(newerManualPath, newerManualMtime,
newerManualMtime) so the ordering is deterministic across Windows and other
filesystems.

In `@test/storage.test.ts`:
- Around line 1269-1291: The test currently asserts a mismatch between the
assessment preview and the actual restore: assessment.skipped is asserted as 0
while restoreNamedBackup("Replace Only") returns skipped: 1; make these
consistent by updating the assessment expectation to
expect(assessment.skipped).toBe(1) (leave assessment.imported and the
restoreResult assertion unchanged) so the preview and apply counts match;
reference the assessment object assertions and the restoreNamedBackup /
restoreResult check to locate the two assertions to adjust.
- Around line 1750-1795: The test "retries transient backup read errors while
restoring backups" currently increments backupReads when the mocked fs.readFile
hits the backupPath but never asserts it; add an explicit assertion that
backupReads equals the expected retry count after calling
restoreNamedBackup("retry-restore-read") (e.g., expect(backupReads).toBe(2) if
one retry was expected), keeping the existing cleanup of
readFileSpy.mockRestore(); ensure the assertion is placed inside the try block
after const result = await restoreNamedBackup(...) and before restoring the spy
so the test deterministically verifies the retry behavior of restoreNamedBackup
and the mocked read path.
🪄 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: 712ffd0d-b23c-4239-b773-91b028d18384

📥 Commits

Reviewing files that changed from the base of the PR and between 56dac78 and 4492f73.

📒 Files selected for processing (5)
  • lib/codex-manager.ts
  • lib/storage.ts
  • test/codex-manager-cli.test.ts
  • test/storage-recovery-paths.test.ts
  • test/storage.test.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Greptile Review
🧰 Additional context used
📓 Path-based instructions (2)
test/**

⚙️ CodeRabbit configuration file

tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions.

Files:

  • test/storage-recovery-paths.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
🔇 Additional comments (7)
lib/codex-manager.ts (1)

4673-4757: good preview gating and confirmation flow.

lib/codex-manager.ts:4673 blocks non-actionable restores before action selection, and lib/codex-manager.ts:4734 adds a clear confirmation that includes replacement/active-account impact.

test/storage-recovery-paths.test.ts (1)

751-753: nice assertion hardening here.

the exact-path assertion in test/storage-recovery-paths.test.ts:751 prevents the prior weak pass condition and locks this regression to the intended winner snapshot.

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

991-1164: good replace-only preview regression coverage

this test exercises inspect → preview-restore → confirm → restore and verifies replace-only messaging, which is the right contract for this flow. good coverage against restore-preview regressions in lib/codex-manager.ts:4695-4755 and lib/codex-manager.ts:4912-4950.


4231-4326: good windows ebusy reassessment guard test

this is a solid windows filesystem edge-case regression: reassessment failure exits safely, skips confirm, and avoids restore. it matches the guard behavior in lib/codex-manager.ts:4704-4718.

test/storage.test.ts (3)

1213-1225: good coverage for explicit-null active-account preview state.

the assertions in test/storage.test.ts:1213-test/storage.test.ts:1225 pin the active-account transition surface well and protect the restore-preview contract.


1301-1338: nice regression for prevalidated assessment reuse.

this is a solid guard for redundant backup reads in the preview→restore path at test/storage.test.ts:1301-test/storage.test.ts:1338, which is where windows ebusy windows tend to show up.


1477-1548: strong concurrency regression for stale assessment race.

test/storage.test.ts:1477-test/storage.test.ts:1548 correctly reproduces the mutation-time limit race and verifies reassessment blocks restore.

Comment on lines 2429 to 2447
export async function restoreNamedBackup(
name: string,
options: { assessment?: BackupRestoreAssessment } = {},
): Promise<{ imported: number; total: number; skipped: number }> {
const backupPath = await resolveNamedBackupRestorePath(name);
return importAccounts(backupPath);
const candidate = await loadImportableBackupCandidate(backupPath);
if (options.assessment !== undefined) {
const assessment = assessNamedBackupRestoreCandidate(
await buildNamedBackupMetadata(name, backupPath, { candidate }),
candidate,
await loadAccounts(),
candidate.rawAccounts,
);
if (!assessment.eligibleForRestore || assessment.wouldExceedLimit) {
throw new Error(assessment.error ?? "Backup is not eligible for restore");
}
}
return importNormalizedAccounts(candidate.normalized, backupPath);
}
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# check preview/restore coverage
rg -nP --type=ts -C3 'preview-restore|Restore Preview|restoreNamedBackup\(' test

# check explicit windows lock-error coverage
rg -nP --type=ts -C3 'EBUSY|EPERM|EAGAIN' test

Repository: ndycode/codex-multi-auth

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check the restoreNamedBackup implementation (lines 2429-2447)
echo "=== restoreNamedBackup function (2429-2447) ==="
sed -n '2429,2447p' lib/storage.ts

echo -e "\n=== loadImportableBackupCandidate definition ==="
rg -nP 'async function loadImportableBackupCandidate' lib/storage.ts -A 5

echo -e "\n=== importNormalizedAccounts definition ==="
rg -nP 'async function importNormalizedAccounts' lib/storage.ts -A 15

Repository: ndycode/codex-multi-auth

Length of output: 1850


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Get full importNormalizedAccounts function
sed -n '2526,2573p' lib/storage.ts

echo -e "\n=== deduplicateAccounts definition ==="
rg -nP 'function deduplicateAccounts' lib/storage.ts -A 20

Repository: ndycode/codex-multi-auth

Length of output: 3151


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify the preview path to understand the flow mentioned in comment
echo "=== codex-manager.ts around line 4702 (preview restore) ==="
sed -n '4695,4710p' lib/codex-manager.ts

echo -e "\n=== Check if assessNamedBackupRestoreCandidate requires fresh load ==="
rg -nP 'function assessNamedBackupRestoreCandidate' lib/storage.ts -A 10

echo -e "\n=== Check preview flow in codex-manager ==="
rg -nP 'async function.*Restore.*Preview|preview.*restore' lib/codex-manager.ts -i -C 3 | head -60

Repository: ndycode/codex-multi-auth

Length of output: 2264


🏁 Script executed:

#!/bin/bash
set -euo pipefandet

# Check the Replace Only test (line 1289)
echo "=== Replace Only test context ==="
sed -n '1265,1295p' test/storage.test.ts

echo -e "\n=== Check retry-restore-read test ==="
sed -n '1775,1805p' test/storage.test.ts

echo -e "\n=== Check retry-restore-dir test ==="
sed -n '1673,1750p' test/storage.test.ts

Repository: ndycode/codex-multi-auth

Length of output: 123


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check the Replace Only test (line 1289)
echo "=== Replace Only test context ==="
sed -n '1265,1295p' test/storage.test.ts

echo -e "\n=== Check retry-restore-read test ==="
sed -n '1775,1805p' test/storage.test.ts

echo -e "\n=== Check retry-restore-dir test ==="
sed -n '1673,1750p' test/storage.test.ts

Repository: ndycode/codex-multi-auth

Length of output: 4437


restore re-opens backup file, widening windows lock exposure.

lib/storage.ts:2434 calls loadImportableBackupCandidate unconditionally, re-reading the backup from disk even after preview has already assessed it at lib/codex-manager.ts:4702. this doubles filesystem touchpoints and increases ebusy/eagain exposure during preview→restore flows on windows under concurrent scanners.

separately, lib/storage.ts:2563 counts replacements as "skipped", causing semantic drift between assessment (which reports skipped: 0 for replace-only) and restore result (which reports skipped: 1). this inconsistency confuses preview/result semantics. the fix: only count accounts actually dropped due to dedup as skipped, not replacements.

test/storage.test.ts already has working coverage for ebusy/eagain retries during restore (test/storage.test.ts:1742, 1790).

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

In `@lib/storage.ts` around lines 2429 - 2447, restoreNamedBackup currently always
calls loadImportableBackupCandidate, re-opening the backup even when the caller
already has the candidate, and importNormalizedAccounts (or the restore counting
logic) treats replacements as "skipped", causing preview/restore semantic drift.
Change restoreNamedBackup(name, options) to accept and use an optional preloaded
candidate (e.g., options.candidate) and only call loadImportableBackupCandidate
when that candidate is not provided; keep the existing
buildNamedBackupMetadata/assessNamedBackupRestoreCandidate flow but pass the
supplied candidate into assessment to avoid re-reading the file. Also update the
account import/counting logic in importNormalizedAccounts (the code that
increments skipped/replacements) so that only accounts actually dropped due to
dedup are counted as skipped and replacements are not counted as skipped,
ensuring assessment.skipped matches restore result.

Comment on lines +1269 to +1291
expect(assessment.backupAccountCount).toBe(1);
expect(assessment.dedupedBackupAccountCount).toBe(1);
expect(assessment.imported).toBe(0);
expect(assessment.skipped).toBe(0);
expect(assessment.replacedExistingCount).toBe(1);
expect(assessment.eligibleForRestore).toBe(true);
expect(assessment.wouldExceedLimit).toBe(false);
expect(assessment.activeAccountOutcome).toBe("unchanged");
expect(assessment.activeAccountChanged).toBe(false);
expect(assessment.namedBackupRestorePreview?.conflicts).toEqual([
expect.objectContaining({
conflict: expect.objectContaining({
backupIndex: 0,
currentIndex: 0,
resolution: "backup-kept",
reasons: expect.arrayContaining(["accountId", "email"]),
}),
}),
]);

const restoreResult = await restoreNamedBackup("Replace Only");
expect(restoreResult).toEqual({ imported: 0, skipped: 1, total: 1 });

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

do not codify a preview/apply skipped-count mismatch.

test/storage.test.ts:1272 expects assessment.skipped to be 0, while test/storage.test.ts:1290 expects restore skipped to be 1 for the same replace-only case. this bakes in inconsistent behavior instead of catching it.

proposed test fix
-			expect(assessment.skipped).toBe(0);
+			// preview/apply counters should stay consistent for the same restore intent.
+			// once lib behavior is corrected, this guards against regressions.
+			// test/storage.test.ts:1272
+			expect(assessment.skipped).toBe(1);
@@
-			const restoreResult = await restoreNamedBackup("Replace Only");
-			expect(restoreResult).toEqual({ imported: 0, skipped: 1, total: 1 });
+			const restoreResult = await restoreNamedBackup("Replace Only");
+			expect(restoreResult).toEqual({ imported: 0, skipped: 1, total: 1 });
+			expect(restoreResult.skipped).toBe(assessment.skipped);
📝 Committable suggestion

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

Suggested change
expect(assessment.backupAccountCount).toBe(1);
expect(assessment.dedupedBackupAccountCount).toBe(1);
expect(assessment.imported).toBe(0);
expect(assessment.skipped).toBe(0);
expect(assessment.replacedExistingCount).toBe(1);
expect(assessment.eligibleForRestore).toBe(true);
expect(assessment.wouldExceedLimit).toBe(false);
expect(assessment.activeAccountOutcome).toBe("unchanged");
expect(assessment.activeAccountChanged).toBe(false);
expect(assessment.namedBackupRestorePreview?.conflicts).toEqual([
expect.objectContaining({
conflict: expect.objectContaining({
backupIndex: 0,
currentIndex: 0,
resolution: "backup-kept",
reasons: expect.arrayContaining(["accountId", "email"]),
}),
}),
]);
const restoreResult = await restoreNamedBackup("Replace Only");
expect(restoreResult).toEqual({ imported: 0, skipped: 1, total: 1 });
expect(assessment.backupAccountCount).toBe(1);
expect(assessment.dedupedBackupAccountCount).toBe(1);
expect(assessment.imported).toBe(0);
// preview/apply counters should stay consistent for the same restore intent.
// once lib behavior is corrected, this guards against regressions.
// test/storage.test.ts:1272
expect(assessment.skipped).toBe(1);
expect(assessment.replacedExistingCount).toBe(1);
expect(assessment.eligibleForRestore).toBe(true);
expect(assessment.wouldExceedLimit).toBe(false);
expect(assessment.activeAccountOutcome).toBe("unchanged");
expect(assessment.activeAccountChanged).toBe(false);
expect(assessment.namedBackupRestorePreview?.conflicts).toEqual([
expect.objectContaining({
conflict: expect.objectContaining({
backupIndex: 0,
currentIndex: 0,
resolution: "backup-kept",
reasons: expect.arrayContaining(["accountId", "email"]),
}),
}),
]);
const restoreResult = await restoreNamedBackup("Replace Only");
expect(restoreResult).toEqual({ imported: 0, skipped: 1, total: 1 });
expect(restoreResult.skipped).toBe(assessment.skipped);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/storage.test.ts` around lines 1269 - 1291, The test currently asserts a
mismatch between the assessment preview and the actual restore:
assessment.skipped is asserted as 0 while restoreNamedBackup("Replace Only")
returns skipped: 1; make these consistent by updating the assessment expectation
to expect(assessment.skipped).toBe(1) (leave assessment.imported and the
restoreResult assertion unchanged) so the preview and apply counts match;
reference the assessment object assertions and the restoreNamedBackup /
restoreResult check to locate the two assertions to adjust.

@ndycode ndycode changed the base branch from fresh/12-backup-browser to dev March 18, 2026 06:47
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

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

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

3127-3148: ⚠️ Potential issue | 🔴 Critical

pin transaction persists to the storage path captured at transaction start.

lib/storage.ts:3134 and lib/storage.ts:3161 capture state.storagePath, but the transaction persist and rollback closures at lib/storage.ts:3141-3143 and lib/storage.ts:3174-3181 still route through saveAccountsUnlocked() / saveFlaggedAccountsUnlocked(), which resolve paths from the live globals at lib/storage.ts:2999 and lib/storage.ts:3446-3447. a concurrent setStoragePath() can make a transaction read repo a and commit into repo b, which is straight data corruption. test/storage.test.ts:369-395 already proves path drift is a real scenario; please add a vitest that flips the path before persist() and asserts only the original files change. as per coding guidelines, lib/**: focus on auth rotation, windows filesystem io, and concurrency. verify every change cites affected tests (vitest) and that new queues handle ebusy/429 scenarios. check for logging that leaks tokens or emails.

Also applies to: 3151-3202

🤖 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 2082-2090: The fast-path in
getActionableNamedBackupRestores()/scannedBackups currently drops
entry.candidate.rawAccounts which causes inconsistent
skipped/conflict/replacedExistingCount math between
assessNamedBackupRestoreCandidate(), restoreNamedBackup(), and preview paths;
update the fast assessment loop to thread entry.candidate.rawAccounts (or the
exact transaction inputs) into assessNamedBackupRestoreCandidate() and use those
inputs when computing skipped/conflict/replacedExistingCount (instead of
normalized.accounts.length or preview-time replacedExistingCount) so counters
are computed from actual transaction inputs; add vitest tests that cover
duplicate raw backup rows and a preview→apply storage race to assert counts
remain identical, and ensure any new queuing/code handles EBUSY/429 retries and
that logging in these changes does not leak tokens/emails.
- Around line 2429-2448: restoreNamedBackup currently trusts a caller-provided
options.assessment without proving the freshly loaded candidate (via
loadImportableBackupCandidate) is the same backup the assessment was built
against; to fix, always compute and compare a stable fingerprint/metadata of the
loaded candidate (use buildNamedBackupMetadata and any stable fields used by
assessNamedBackupRestoreCandidate such as checksum/mtime/size or an explicit id)
against the assessment passed in options, and if they differ re-run
assessNamedBackupRestoreCandidate (or reject with a clear error) before calling
importNormalizedAccounts; update restoreNamedBackup to reference
loadImportableBackupCandidate, buildNamedBackupMetadata,
assessNamedBackupRestoreCandidate and importNormalizedAccounts accordingly and
add a vitest that simulates a file-modify-between-preview-and-apply case (same
name, changed valid JSON) to assert the operation is rejected or re-assessed.

In `@test/storage.test.ts`:
- Around line 1424-1478: The tests "throws when a named backup is deleted after
assessment" and "throws when a named backup becomes invalid JSON after
assessment" call assessNamedBackupRestore(...) to get an assessment but then
call restoreNamedBackup(...) without passing that assessment; update both
restoreNamedBackup calls to pass the previewed result (e.g.
restoreNamedBackup("deleted-after-assessment", { assessment }) and
restoreNamedBackup("invalid-after-assessment", { assessment })) so the tests
exercise the prevalidated/previewed restore path used in lib/codex-manager.ts
(restoreNamedBackup and assessNamedBackupRestore).
🪄 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: 17d66d4c-8201-4556-9326-9e58f7786fd9

📥 Commits

Reviewing files that changed from the base of the PR and between 4492f73 and 0f03325.

📒 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

Comment on lines +2082 to +2090
if (usesFastPath) {
for (const entry of scannedBackups) {
try {
const assessment = assessNamedBackupRestoreCandidate(
entry.backup,
entry.candidate,
currentStorage,
);
recordAssessment(assessment);
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

thread raw backup rows through every preview/apply path.

lib/storage.ts:2085-2089 drops entry.candidate.rawAccounts on the fast assessment path, and lib/storage.ts:2563-2569 derives skipped from normalized.accounts.length. backups with duplicate raw rows will therefore report different conflict/skipped counts between getActionableNamedBackupRestores(), assessNamedBackupRestore(), and restoreNamedBackup(). restoreNamedBackup() also feeds preview-time replacedExistingCount into this math from lib/storage.ts:2446-2448, so a current-storage race can skew the apply result even when the file is unchanged. the existing replace-only case at test/storage.test.ts:1228-1293 will not catch either drift. please compute these counters from the actual transaction inputs and add vitest coverage for duplicate backup rows plus preview→apply storage races. as per coding guidelines, lib/**: focus on auth rotation, windows filesystem io, and concurrency. verify every change cites affected tests (vitest) and that new queues handle ebusy/429 scenarios. check for logging that leaks tokens or emails.

Also applies to: 2528-2570

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

In `@lib/storage.ts` around lines 2082 - 2090, The fast-path in
getActionableNamedBackupRestores()/scannedBackups currently drops
entry.candidate.rawAccounts which causes inconsistent
skipped/conflict/replacedExistingCount math between
assessNamedBackupRestoreCandidate(), restoreNamedBackup(), and preview paths;
update the fast assessment loop to thread entry.candidate.rawAccounts (or the
exact transaction inputs) into assessNamedBackupRestoreCandidate() and use those
inputs when computing skipped/conflict/replacedExistingCount (instead of
normalized.accounts.length or preview-time replacedExistingCount) so counters
are computed from actual transaction inputs; add vitest tests that cover
duplicate raw backup rows and a preview→apply storage race to assert counts
remain identical, and ensure any new queuing/code handles EBUSY/429 retries and
that logging in these changes does not leak tokens/emails.

Comment on lines +2429 to +2448
export async function restoreNamedBackup(
name: string,
options: { assessment?: BackupRestoreAssessment } = {},
): Promise<{ imported: number; total: number; skipped: number }> {
const backupPath = await resolveNamedBackupRestorePath(name);
const candidate = await loadImportableBackupCandidate(backupPath);
const assessment =
options.assessment ??
assessNamedBackupRestoreCandidate(
await buildNamedBackupMetadata(name, backupPath, { candidate }),
candidate,
await loadAccounts(),
candidate.rawAccounts,
);
if (!assessment.eligibleForRestore) {
throw new Error(assessment.error ?? "Backup is not eligible for restore");
}
return importNormalizedAccounts(candidate.normalized, backupPath, {
replacedExistingCount: assessment.replacedExistingCount ?? 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

revalidate the preview against the file you actually import.

lib/storage.ts:2433-2444 will honor options.assessment without proving the candidate loaded at lib/storage.ts:2434 is still the same backup the user previewed. if that file is rewritten with new valid contents between preview and apply, restore can import different accounts under stale eligibility and counter data. test/storage.test.ts:1424-1478 only covers missing and invalid files, so please add a vitest for the { assessment } path where the backup keeps the same name but new valid json. as per coding guidelines, lib/**: focus on auth rotation, windows filesystem io, and concurrency. verify every change cites affected tests (vitest) and that new queues handle ebusy/429 scenarios. check for logging that leaks tokens or emails.

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

In `@lib/storage.ts` around lines 2429 - 2448, restoreNamedBackup currently trusts
a caller-provided options.assessment without proving the freshly loaded
candidate (via loadImportableBackupCandidate) is the same backup the assessment
was built against; to fix, always compute and compare a stable
fingerprint/metadata of the loaded candidate (use buildNamedBackupMetadata and
any stable fields used by assessNamedBackupRestoreCandidate such as
checksum/mtime/size or an explicit id) against the assessment passed in options,
and if they differ re-run assessNamedBackupRestoreCandidate (or reject with a
clear error) before calling importNormalizedAccounts; update restoreNamedBackup
to reference loadImportableBackupCandidate, buildNamedBackupMetadata,
assessNamedBackupRestoreCandidate and importNormalizedAccounts accordingly and
add a vitest that simulates a file-modify-between-preview-and-apply case (same
name, changed valid JSON) to assert the operation is rejected or re-assessed.

Comment on lines +1424 to +1478
it("throws when a named backup is deleted after assessment", async () => {
await saveAccounts({
version: 3,
activeIndex: 0,
accounts: [
{
accountId: "deleted-backup",
refreshToken: "ref-deleted-backup",
addedAt: 1,
lastUsed: 1,
},
],
});

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

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

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

await expect(
restoreNamedBackup("deleted-after-assessment"),
).rejects.toThrow(/Import file not found/);
expect((await loadAccounts())?.accounts ?? []).toHaveLength(0);
});

it("throws when a named backup becomes invalid JSON after assessment", async () => {
await saveAccounts({
version: 3,
activeIndex: 0,
accounts: [
{
accountId: "invalid-backup",
refreshToken: "ref-invalid-backup",
addedAt: 1,
lastUsed: 1,
},
],
});

const backup = await createNamedBackup("invalid-after-assessment");
await clearAccounts();

const assessment = await assessNamedBackupRestore("invalid-after-assessment");
expect(assessment.eligibleForRestore).toBe(true);

await fs.writeFile(backup.path, "not valid json {[", "utf-8");

await expect(
restoreNamedBackup("invalid-after-assessment"),
).rejects.toThrow(/Invalid JSON in import file/);
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

exercise the prevalidated restore path in these stale-backup tests.

lib/codex-manager.ts:4700-4710 and lib/codex-manager.ts:4952-4987 pass the previewed assessment into restoreNamedBackup(), but test/storage.test.ts:1446-1476 does not. as written, both tests only cover a fresh restore, not the preview→apply path this pr adds. pass { assessment } into each restore so the suite actually proves stale previews still fail after deletion or file corruption.

proposed test fix
-			await expect(
-				restoreNamedBackup("deleted-after-assessment"),
-			).rejects.toThrow(/Import file not found/);
+			await expect(
+				restoreNamedBackup("deleted-after-assessment", { assessment }),
+			).rejects.toThrow(/Import file not found/);
@@
-			await expect(
-				restoreNamedBackup("invalid-after-assessment"),
-			).rejects.toThrow(/Invalid JSON in import file/);
+			await expect(
+				restoreNamedBackup("invalid-after-assessment", { assessment }),
+			).rejects.toThrow(/Invalid JSON in import file/);

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 1424 - 1478, The tests "throws when a
named backup is deleted after assessment" and "throws when a named backup
becomes invalid JSON after assessment" call assessNamedBackupRestore(...) to get
an assessment but then call restoreNamedBackup(...) without passing that
assessment; update both restoreNamedBackup calls to pass the previewed result
(e.g. restoreNamedBackup("deleted-after-assessment", { assessment }) and
restoreNamedBackup("invalid-after-assessment", { assessment })) so the tests
exercise the prevalidated/previewed restore path used in lib/codex-manager.ts
(restoreNamedBackup and assessNamedBackupRestore).

Comment on lines 3511 to +3540
}
const backupPaths =
await getAccountsBackupRecoveryCandidatesWithDiscovery(path);
for (const candidate of [path, ...backupPaths, markerPath]) {
let hadError = false;
for (const candidate of [path, ...backupPaths]) {
try {
await fs.unlink(candidate);
await unlinkWithRetry(candidate);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
hadError = true;
log.error("Failed to clear flagged account storage", {
path: candidate,
error: String(error),
});
if (candidate === path) {
throw error;
}
}
}
}
if (!hadError) {
try {
await unlinkWithRetry(markerPath);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
log.error("Failed to clear flagged reset marker", {
path,
markerPath,
error: String(error),
});
hadError = true;
Copy link

Choose a reason for hiding this comment

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

P1 reset marker kept alive when only a backup file fails to delete — false-positive intent signal on next startup

old clearFlaggedAccounts deleted markerPath inside the same loop as [path, ...backupPaths]; if a backup delete failed it continued and still cleaned up the marker. new code gates marker deletion on !hadError, so a single EBUSY/EPERM on any .bak file leaves the reset intent marker on disk even though the primary flagged-accounts file was successfully removed.

on the next startup the lingering marker may suppress WAL replay or trigger "reset in progress" recovery logic for flagged accounts that are actually clean, which silently discards valid recovery state. the same pattern was applied in clearAccounts (the hadError + unlinkWithRetry refactor is good), but flagged accounts are a distinct concern because their reset marker is separate.

consider deleting the marker unconditionally if the primary path delete succeeded, regardless of backup cleanup failures:

let primaryCleared = false;
for (const candidate of [path, ...backupPaths]) { ... }

// always attempt marker cleanup if main file is gone
if (primaryCleared || !hadError) {
    try { await unlinkWithRetry(markerPath); } catch { ... }
}

no vitest covers the scenario where unlinkWithRetry succeeds on path but fails on a backup path and the marker state is verified.

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

Comment:
**reset marker kept alive when only a backup file fails to delete — false-positive intent signal on next startup**

old `clearFlaggedAccounts` deleted `markerPath` inside the same loop as `[path, ...backupPaths]`; if a backup delete failed it continued and still cleaned up the marker. new code gates marker deletion on `!hadError`, so a single EBUSY/EPERM on any `.bak` file leaves the reset intent marker on disk even though the primary flagged-accounts file was successfully removed.

on the next startup the lingering marker may suppress WAL replay or trigger "reset in progress" recovery logic for flagged accounts that are actually clean, which silently discards valid recovery state. the same pattern was applied in `clearAccounts` (the `hadError` + `unlinkWithRetry` refactor is good), but flagged accounts are a distinct concern because their reset marker is separate.

consider deleting the marker unconditionally if the primary `path` delete succeeded, regardless of backup cleanup failures:

```ts
let primaryCleared = false;
for (const candidate of [path, ...backupPaths]) { ... }

// always attempt marker cleanup if main file is gone
if (primaryCleared || !hadError) {
    try { await unlinkWithRetry(markerPath); } catch { ... }
}
```

no vitest covers the scenario where `unlinkWithRetry` succeeds on `path` but fails on a backup path and the marker state is verified.

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

Fix in Codex

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