Conversation
|
Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits. |
📝 Walkthroughwalkthroughintroduces a startup recovery prompt during interactive login when no saved accounts exist but recoverable named backups are present. implements a named-backup subsystem (scan, assess, restore), integrates a backup-restore manager into the login flow, adds public storage APIs, and expands tests accordingly. changes
sequence diagramsequenceDiagram
participant client as "interactive cli"
participant codex as "codex manager"
participant storage as "storage layer"
participant restore as "backup restore manager"
participant auth as "oauth/auth"
client->>codex: promptLoginMode() (interactive tty)
codex->>codex: isInteractiveLoginMenuAvailable()
codex->>storage: getActionableNamedBackupRestores()
storage->>storage: scanNamedBackups() + assessNamedBackupRestore()
storage-->>codex: actionable recoveries
codex->>codex: resolveStartupRecoveryAction()
alt show recovery prompt
codex->>restore: runBackupRestoreManager()
restore->>client: show restore menu
client->>restore: select backup
restore->>storage: restoreNamedBackup(name)
storage-->>restore: ImportAccountsResult
restore->>auth: post-restore auth sync
restore-->>codex: restored | dismissed | failed
else continue oauth
codex->>auth: continue standard login
end
auth-->>client: auth complete
estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes flagged issues
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
✨ Simplify code
📝 Coding Plan
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
Reintroduces and wires up a startup recovery prompt to offer named-backup restore options before OAuth when interactive login starts with an empty account store.
Changes:
- Adds
getActionableNamedBackupRestores()+ startup decision logic (resolveStartupRecoveryAction) to drive the recovery prompt flow. - Refactors named-backup listing/assessment/restore plumbing and related tests.
- Updates onboarding/upgrade/troubleshooting docs to describe the new startup recovery prompt behavior.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| lib/storage.ts | Adds actionable-backup scanning API; refactors backup listing/assessment/restore + import/export behavior. |
| lib/codex-manager.ts | Implements startup recovery prompt flow and restore-backup command refactor. |
| lib/cli.ts | Centralizes “interactive menu available” detection for login/menu flows. |
| test/storage.test.ts | Updates storage/backup tests to match new restore/import behaviors and isolation patterns. |
| test/storage-recovery-paths.test.ts | Adjusts backup metadata ordering expectations by manipulating checkpoint mtimes. |
| test/recovery.test.ts | Adds coverage for new storage-backed recovery scanning + refactors existing recovery tests. |
| lib/ui/copy.ts | Removes restore-backup UI copy helpers in favor of inline messaging. |
| docs/getting-started.md | Documents the startup recovery prompt in the login flow. |
| docs/upgrade.md | Adds an upgrade note explaining the new startup recovery prompt behavior. |
| docs/troubleshooting.md | Notes when/why startup recovery prompting occurs. |
| docs/reference/public-api.md | Removes note about importAccounts().changed return shape. |
Comments suppressed due to low confidence (1)
lib/storage.ts:1
- The import result math can produce invalid values:
importedcan be negative ifexistingAccountscontains duplicates (since it’s not deduped), andskippedcan become negative or exceedtotalas a result. Align the counting with the same deduplication strategy used to builddeduplicatedAccounts(dedupe the existing snapshot for baseline), and clampskippedto>= 0after using consistent units (deduped incoming vs. raw incoming).
import { AsyncLocalStorage } from "node:async_hooks";
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
lib/storage.ts
Outdated
| const mergedAccounts = deduplicateAccounts([ | ||
| ...currentDeduplicatedAccounts, | ||
| ...incomingDeduplicatedAccounts, | ||
| ...currentAccounts, | ||
| ...candidate.normalized.accounts, | ||
| ]); | ||
| const wouldExceedLimit = mergedAccounts.length > ACCOUNT_LIMITS.MAX_ACCOUNTS; | ||
| const imported = wouldExceedLimit | ||
| ? null | ||
| : mergedAccounts.length - currentDeduplicatedAccounts.length; | ||
| : mergedAccounts.length - currentAccounts.length; | ||
| const skipped = wouldExceedLimit | ||
| ? null | ||
| : Math.max(0, incomingDeduplicatedAccounts.length - (imported ?? 0)); | ||
| const changed = !haveEquivalentAccountRows( | ||
| mergedAccounts, | ||
| currentDeduplicatedAccounts, | ||
| ); | ||
| : Math.max(0, candidate.normalized.accounts.length - (imported ?? 0)); |
| const transactionState = transactionSnapshotContext.getStore(); | ||
| const currentStoragePath = normalizeStoragePathForComparison(getStoragePath()); | ||
| const currentStoragePath = getStoragePath(); | ||
| const storage = transactionState?.active | ||
| ? normalizeStoragePathForComparison(transactionState.storagePath) === | ||
| currentStoragePath | ||
| ? transactionState.storagePath === currentStoragePath | ||
| ? transactionState.snapshot |
lib/storage.ts
Outdated
| export async function importAccounts( | ||
| filePath: string, | ||
| ): Promise<ImportAccountsResult> { | ||
| ): Promise<{ imported: number; total: number; skipped: number }> { |
lib/storage.ts
Outdated
| } | ||
| throw error; | ||
| } | ||
| const content = await fs.readFile(resolvedPath, "utf-8"); |
lib/codex-manager.ts
Outdated
| if (timestamp === null || timestamp === undefined || timestamp === 0) | ||
| return null; | ||
| if (!Number.isFinite(timestamp)) return null; | ||
| if (timestamp === null || timestamp === undefined) return null; |
| async function removeWithRetry(targetPath: string): Promise<void> { | ||
| for (let attempt = 0; attempt < 5; attempt += 1) { | ||
| try { | ||
| await fs.rm(targetPath, { recursive: true, force: true }); | ||
| return; | ||
| } catch (error) { | ||
| const code = (error as NodeJS.ErrnoException).code; | ||
| if ( | ||
| (code !== "EBUSY" && code !== "EPERM" && code !== "ENOTEMPTY") || | ||
| attempt === 4 | ||
| ) { | ||
| throw error; | ||
| } | ||
| await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Actionable comments posted: 6
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)
1942-1985:⚠️ Potential issue | 🟡 Minorcompute
importedagainst the deduped current state here.
lib/storage.ts:1942-1985still subtractscurrentAccounts.length, while the write path inlib/storage.ts:3008-3042was already fixed to measure against deduped existing storage. if a caller passes duplicate-ladencurrentStorage,getActionableNamedBackupRestores()can drop a genuinely useful restore because theassessment.imported > 0filter inlib/storage.ts:1849-1857sees0or a negative count. there is no matching assessment regression besidetest/storage.test.ts:587-630.suggested fix
function assessNamedBackupRestoreCandidate( backup: NamedBackupMetadata, candidate: LoadedBackupCandidate, currentStorage: AccountStorageV3 | null, ): BackupRestoreAssessment { const currentAccounts = currentStorage?.accounts ?? []; + const effectiveCurrentAccounts = deduplicateAccounts([...currentAccounts]); if (!candidate.normalized || !backup.accountCount || backup.accountCount <= 0) { return { backup, @@ } const mergedAccounts = deduplicateAccounts([ - ...currentAccounts, + ...effectiveCurrentAccounts, ...candidate.normalized.accounts, ]); const wouldExceedLimit = mergedAccounts.length > ACCOUNT_LIMITS.MAX_ACCOUNTS; const imported = wouldExceedLimit ? null - : mergedAccounts.length - currentAccounts.length; + : mergedAccounts.length - effectiveCurrentAccounts.length;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 1942 - 1985, In assessNamedBackupRestoreCandidate, imported and skipped are currently computed against currentAccounts.length (which may include duplicates); change the calculations to use the deduplicated current state (mergedAccounts minus deduplicated current count) — i.e., compute dedupedCurrentCount = deduplicateAccounts(currentAccounts).length (or use mergedAccounts.length and dedupedCurrentCount) and set imported = wouldExceedLimit ? null : mergedAccounts.length - dedupedCurrentCount, and recalc skipped = wouldExceedLimit ? null : Math.max(0, candidate.normalized.accounts.length - (imported ?? 0)); keep wouldExceedLimit and error logic intact and ensure eligibleForRestore uses !wouldExceedLimit.
🤖 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/cli.ts`:
- Around line 266-268: The check calling isInteractiveLoginMenuAvailable()
before returning promptLoginModeFallback(existingAccounts) is redundant because
the same non-interactive early return gate is already handled earlier; remove
the conditional block that wraps promptLoginModeFallback(existingAccounts) (the
isInteractiveLoginMenuAvailable() check and its return) so
promptLoginModeFallback is only reached via the existing early-return logic,
leaving other code paths and function names (isInteractiveLoginMenuAvailable,
promptLoginModeFallback) intact.
In `@lib/codex-manager.ts`:
- Around line 4391-4411: Wrap each assessNamedBackupRestore(backup.name, {
currentStorage }) call with a transient-retry wrapper before invoking
Promise.allSettled: on error check error.code (handle at least
"EBUSY","EPERM","EACCES","EAGAIN" and HTTP 429-like transient indications) and
retry with small exponential backoff (e.g., 3 total attempts with increasing
delays) logging each retry attempt but avoid printing sensitive data
(tokens/emails); only if all attempts fail, let the rejection propagate so the
existing settledAssessments handling logs the final skip message using
collapseWhitespace and backup.name as before; update or add vitest cases
exercising transient EBUSY/EPERM and 429 behavior to assert retries occur and
that final logs do not leak secrets.
- Around line 4062-4142: Add vitest regression tests exercising the multi-branch
startup recovery flow in codex-manager: simulate
getActionableNamedBackupRestores throwing a filesystem error (use
getRedactedFilesystemErrorLabel expectations) to assert the scan failure path
logs and continues OAuth; simulate it returning empty assessments to assert
resolveStartupRecoveryAction triggers the "open-empty-storage-menu" branch
(allowEmptyStorageMenu behavior); simulate recoverable assessments where
runBackupRestoreManager returns a non-"restored" value to assert
pendingRecoveryState is set and recoveryPromptAttempted is reset; and simulate
confirm/restore interactions where confirm throws to exercise the prompt failure
branch ensuring recoveryPromptAttempted is reset only when promptWasShown is
false; stub/mocks to manipulate applyUiThemeFromDashboardSettings,
loadDashboardDisplaySettings, getNamedBackupsDirectoryPath, and confirm to
isolate behavior and assert state transitions on recoveryPromptAttempted and
pendingRecoveryState.
In `@lib/storage.ts`:
- Around line 1988-1993: The change removed the exported boolean "changed" from
result objects, causing a silent API break; update the functions that return the
import result (e.g., restoreNamedBackup and the functions around the other diff
region that currently return { imported, total, skipped }) to include a boolean
changed property derived as (imported > 0) before returning, and ensure
importAccounts (and any other exported helpers that produce the result type)
also returns the same shape so callers still see result.changed.
- Around line 1720-1770: The current catch in listNamedBackupsWithoutLoading
collapses any readdir failure into an empty listing; change the error handling
so that only ENOENT returns {backups: [], totalBackups: 0} and any other error
is rethrown (i.e., in the catch for the top-level
retryTransientFilesystemOperation call inspect (error as
NodeJS.ErrnoException).code and if !== "ENOENT" throw the error), leaving the
per-entry ENOENT swallowing in place; update callers/tests that rely on the
distinction (the recovery tests referenced around the existing test ranges) to
expect a thrown error for locked/EBUSY directory cases instead of an empty
result.
- Around line 2987-2994: Remove the pre-check that uses existsSync and instead
let fs.readFile (inside retryTransientFilesystemOperation) surface errors; catch
errors from the read of resolvedPath and translate an ENOENT into the friendly
throw new Error(`Import file not found: ${resolvedPath}`) while rethrowing other
errors unchanged. Update the block that calls
retryTransientFilesystemOperation(() => fs.readFile(resolvedPath, "utf-8")) to
wrap that call in a try/catch, check err.code === 'ENOENT' and throw the
user-friendly message; keep retryTransientFilesystemOperation, resolvedPath, and
fs.readFile references so the change is localized. Also adjust/extend
test/storage.test.ts:1356-1410 to add a delete-between-check-and-read scenario
(simulate removal during the operation) to ensure the ENOENT branch is covered
and no raw fs errors leak.
---
Outside diff comments:
In `@lib/storage.ts`:
- Around line 1942-1985: In assessNamedBackupRestoreCandidate, imported and
skipped are currently computed against currentAccounts.length (which may include
duplicates); change the calculations to use the deduplicated current state
(mergedAccounts minus deduplicated current count) — i.e., compute
dedupedCurrentCount = deduplicateAccounts(currentAccounts).length (or use
mergedAccounts.length and dedupedCurrentCount) and set imported =
wouldExceedLimit ? null : mergedAccounts.length - dedupedCurrentCount, and
recalc skipped = wouldExceedLimit ? null : Math.max(0,
candidate.normalized.accounts.length - (imported ?? 0)); keep wouldExceedLimit
and error logic intact and ensure eligibleForRestore uses !wouldExceedLimit.
🪄 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: 36ff8d22-40b5-4db9-b368-e1d6b7e7891b
📒 Files selected for processing (12)
docs/getting-started.mddocs/reference/public-api.mddocs/troubleshooting.mddocs/upgrade.mdlib/cli.tslib/codex-manager.tslib/storage.tslib/ui/copy.tstest/codex-manager-cli.test.tstest/recovery.test.tstest/storage-recovery-paths.test.tstest/storage.test.ts
💤 Files with no reviewable changes (2)
- docs/reference/public-api.md
- lib/ui/copy.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)
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/getting-started.mddocs/troubleshooting.mddocs/upgrade.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.tslib/storage.tslib/codex-manager.ts
test/**
⚙️ CodeRabbit configuration file
tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions.
Files:
test/storage.test.tstest/storage-recovery-paths.test.tstest/recovery.test.ts
| const canPromptForRecovery = | ||
| !suppressRecoveryPrompt && | ||
| !recoveryPromptAttempted && | ||
| existingCount === 0 && | ||
| isInteractiveLoginMenuAvailable(); | ||
| if (canPromptForRecovery) { | ||
| recoveryPromptAttempted = true; | ||
| let recoveryState: Awaited< | ||
| ReturnType<typeof getActionableNamedBackupRestores> | ||
| > | null = pendingRecoveryState; | ||
| pendingRecoveryState = null; | ||
| if (recoveryState === null) { | ||
| let recoveryScanFailed = false; | ||
| let scannedRecoveryState: Awaited< | ||
| ReturnType<typeof getActionableNamedBackupRestores> | ||
| >; | ||
| try { | ||
| scannedRecoveryState = await getActionableNamedBackupRestores({ | ||
| currentStorage: refreshedStorage, | ||
| }); | ||
| } catch (error) { | ||
| recoveryScanFailed = true; | ||
| const errorLabel = getRedactedFilesystemErrorLabel(error); | ||
| console.warn( | ||
| `Startup recovery scan failed (${errorLabel}). Continuing with OAuth.`, | ||
| ); | ||
| scannedRecoveryState = { | ||
| assessments: [], | ||
| allAssessments: [], | ||
| totalBackups: 0, | ||
| }; | ||
| } | ||
| recoveryState = scannedRecoveryState; | ||
| if ( | ||
| resolveStartupRecoveryAction(scannedRecoveryState, recoveryScanFailed) === | ||
| "open-empty-storage-menu" | ||
| ) { | ||
| allowEmptyStorageMenu = true; | ||
| continue loginFlow; | ||
| } | ||
| } | ||
| if (recoveryState.assessments.length > 0) { | ||
| let promptWasShown = false; | ||
| try { | ||
| const displaySettings = await loadDashboardDisplaySettings(); | ||
| applyUiThemeFromDashboardSettings(displaySettings); | ||
| const backupDir = getNamedBackupsDirectoryPath(); | ||
| const backupLabel = | ||
| recoveryState.assessments.length === 1 | ||
| ? recoveryState.assessments | ||
| .map((assessment) => assessment.backup.name) | ||
| .join("") | ||
| : `${recoveryState.assessments.length} backups`; | ||
| promptWasShown = true; | ||
| const restoreNow = await confirm( | ||
| `Found ${recoveryState.assessments.length} recoverable backup${ | ||
| recoveryState.assessments.length === 1 ? "" : "s" | ||
| } out of ${recoveryState.totalBackups} total (${backupLabel}) in ${backupDir}. Restore now?`, | ||
| ); | ||
| if (restoreNow) { | ||
| const restoreResult = await runBackupRestoreManager( | ||
| displaySettings, | ||
| recoveryState.allAssessments, | ||
| ); | ||
| if (restoreResult !== "restored") { | ||
| pendingRecoveryState = recoveryState; | ||
| recoveryPromptAttempted = false; | ||
| } | ||
| continue; | ||
| } | ||
| } catch (error) { | ||
| if (!promptWasShown) { | ||
| recoveryPromptAttempted = false; | ||
| } | ||
| const errorLabel = getRedactedFilesystemErrorLabel(error); | ||
| console.warn( | ||
| `Startup recovery prompt failed (${errorLabel}). Continuing with OAuth.`, | ||
| ); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== candidate recovery/auth tests =="
fd -t f '.*(recovery|auth|login).*test\.(ts|tsx)$' test
echo
echo "== references to new startup-recovery symbols in tests =="
rg -n -C2 'resolveStartupRecoveryAction|show-recovery-prompt|open-empty-storage-menu|continue-with-oauth|suppressRecoveryPrompt|recoveryPromptAttempted|allowEmptyStorageMenu|pendingRecoveryState' test
echo
echo "== references to fresh/reset suppression in tests =="
rg -n -C2 'fresh|reset|startup recovery|recovery prompt' testRepository: ndycode/codex-multi-auth
Length of output: 50381
🏁 Script executed:
wc -l test/recovery.test.tsRepository: ndycode/codex-multi-auth
Length of output: 94
🏁 Script executed:
sed -n '783,900p' test/recovery.test.tsRepository: ndycode/codex-multi-auth
Length of output: 3861
🏁 Script executed:
rg -n 'suppressRecoveryPrompt|recoveryPromptAttempted|pendingRecoveryState|allowEmptyStorageMenu' test/recovery.test.tsRepository: ndycode/codex-multi-auth
Length of output: 50
🏁 Script executed:
rg -n -A5 'restore.*manager|runBackupRestoreManager' test/recovery.test.tsRepository: ndycode/codex-multi-auth
Length of output: 50
🏁 Script executed:
sed -n '4062,4142p' lib/codex-manager.tsRepository: ndycode/codex-multi-auth
Length of output: 2719
🏁 Script executed:
rg -n 'canPromptForRecovery|suppressRecoveryPrompt|recoveryPromptAttempted|pendingRecoveryState' lib/codex-manager.ts | head -20Repository: ndycode/codex-multi-auth
Length of output: 709
🏁 Script executed:
fd -t f '.*(integration|e2e|startup).*test\.(ts|tsx)$' testRepository: ndycode/codex-multi-auth
Length of output: 139
🏁 Script executed:
rg -n 'loginFlow\|runBackupRestoreManager' test/ --type ts -A3 | head -30Repository: ndycode/codex-multi-auth
Length of output: 50
🏁 Script executed:
rg -n 'loginFlow|startup.*recovery|recovery.*prompt' test/oauth-server.integration.test.ts test/rotation-integration.test.tsRepository: ndycode/codex-multi-auth
Length of output: 50
🏁 Script executed:
rg -n 'EBUSY|429|rate.*limit' lib/codex-manager.ts | head -20Repository: ndycode/codex-multi-auth
Length of output: 711
🏁 Script executed:
rg -n 'getRedactedFilesystemErrorLabel' lib/codex-manager.ts -B2 -A5 | head -40Repository: ndycode/codex-multi-auth
Length of output: 1321
🏁 Script executed:
rg -n -C3 'console.warn.*recovery' lib/codex-manager.tsRepository: ndycode/codex-multi-auth
Length of output: 50
add vitest regression coverage for startup recovery state transitions.
lib/codex-manager.ts:4062-4142 adds a multi-branch state machine (suppressRecoveryPrompt, recoveryPromptAttempted, pendingRecoveryState, allowEmptyStorageMenu) with only unit test coverage of the resolveStartupRecoveryAction() helper. missing regression tests for:
- scan failure recovery path (continues oauth, logs redacted error)
- empty scan path (re-enters empty storage menu)
- same-session suppression reset when restore manager returns non-
"restored"value (lib/codex-manager.ts:4127-4128) - prompt failure path with
promptWasShownguard resettingrecoveryPromptAttempted(lib/codex-manager.ts:4134)
also add explicit coverage for windows filesystem io errors (ebusy scenarios) in the recovery scan path (lib/codex-manager.ts:4082-4087), since the current implementation catches but does not retry on transient errors.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@lib/codex-manager.ts` around lines 4062 - 4142, Add vitest regression tests
exercising the multi-branch startup recovery flow in codex-manager: simulate
getActionableNamedBackupRestores throwing a filesystem error (use
getRedactedFilesystemErrorLabel expectations) to assert the scan failure path
logs and continues OAuth; simulate it returning empty assessments to assert
resolveStartupRecoveryAction triggers the "open-empty-storage-menu" branch
(allowEmptyStorageMenu behavior); simulate recoverable assessments where
runBackupRestoreManager returns a non-"restored" value to assert
pendingRecoveryState is set and recoveryPromptAttempted is reset; and simulate
confirm/restore interactions where confirm throws to exercise the prompt failure
branch ensuring recoveryPromptAttempted is reset only when promptWasShown is
false; stub/mocks to manipulate applyUiThemeFromDashboardSettings,
loadDashboardDisplaySettings, getNamedBackupsDirectoryPath, and confirm to
isolate behavior and assert state transitions on recoveryPromptAttempted and
pendingRecoveryState.
| const settledAssessments = await Promise.allSettled( | ||
| chunk.map((backup) => | ||
| assessNamedBackupRestore(backup.name, { | ||
| currentStorage, | ||
| candidateCache, | ||
| }), | ||
| assessNamedBackupRestore(backup.name, { currentStorage }), | ||
| ), | ||
| ); | ||
| for (const [resultIndex, result] of settledAssessments.entries()) { | ||
| if (result.status === "fulfilled") { | ||
| assessments.push(result.value); | ||
| continue; | ||
| } | ||
| if (isNamedBackupContainmentError(result.reason)) { | ||
| throw result.reason; | ||
| } | ||
| const backupName = chunk[resultIndex]?.name ?? "unknown"; | ||
| const reason = | ||
| result.reason instanceof Error | ||
| ? result.reason.message | ||
| : String(result.reason); | ||
| const normalizedReason = | ||
| collapseWhitespace(reason) || "unknown error"; | ||
| assessmentFailures.push(`${backupName}: ${normalizedReason}`); | ||
| console.warn( | ||
| `Skipped backup assessment for "${backupName}": ${normalizedReason}`, | ||
| `Skipped backup assessment for "${backupName}": ${ | ||
| collapseWhitespace(reason) || "unknown error" | ||
| }`, | ||
| ); | ||
| } |
There was a problem hiding this comment.
add transient fs retry before skipping backup assessments.
in lib/codex-manager.ts:4391-4411, assessment failures are skipped immediately. on windows, transient ebusy/eperm can hide recoverable backups and suppress the startup recovery prompt for this run.
proposed hardening
+async function assessNamedBackupWithRetry(
+ backupName: string,
+ currentStorage: Awaited<ReturnType<typeof loadAccounts>>,
+ maxAttempts = 3,
+): Promise<BackupRestoreAssessment> {
+ let lastError: unknown;
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
+ try {
+ return await assessNamedBackupRestore(backupName, { currentStorage });
+ } catch (error) {
+ lastError = error;
+ const code = (error as { code?: string })?.code;
+ const retryable = code === "EBUSY" || code === "EPERM" || code === "EMFILE";
+ if (!retryable || attempt === maxAttempts) throw error;
+ }
+ }
+ throw lastError;
+}
...
- const settledAssessments = await Promise.allSettled(
- chunk.map((backup) =>
- assessNamedBackupRestore(backup.name, { currentStorage }),
- ),
- );
+ const settledAssessments = await Promise.allSettled(
+ chunk.map((backup) =>
+ assessNamedBackupWithRetry(backup.name, currentStorage),
+ ),
+ );As per coding guidelines, lib/**: focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@lib/codex-manager.ts` around lines 4391 - 4411, Wrap each
assessNamedBackupRestore(backup.name, { currentStorage }) call with a
transient-retry wrapper before invoking Promise.allSettled: on error check
error.code (handle at least "EBUSY","EPERM","EACCES","EAGAIN" and HTTP 429-like
transient indications) and retry with small exponential backoff (e.g., 3 total
attempts with increasing delays) logging each retry attempt but avoid printing
sensitive data (tokens/emails); only if all attempts fail, let the rejection
propagate so the existing settledAssessments handling logs the final skip
message using collapseWhitespace and backup.name as before; update or add vitest
cases exercising transient EBUSY/EPERM and 429 behavior to assert retries occur
and that final logs do not leak secrets.
There was a problem hiding this comment.
Actionable comments posted: 5
♻️ Duplicate comments (2)
lib/codex-manager.ts (2)
3840-3851:⚠️ Potential issue | 🟠 Majoradd vitest coverage for the new startup recovery state machine before merge.
line [4064] through line [4144] introduces multi-flag control flow that can regress silently without branch tests. i do not see evidence in this patch context that all new transitions are asserted (scan failure, open-empty-menu routing, prompt failure before/after prompt shown, non-restored manager result resetting state). refs:
lib/codex-manager.ts:3840,lib/codex-manager.ts:4064,test/recovery.test.ts:783.#!/bin/bash set -euo pipefail echo "== startup recovery symbols in tests ==" rg -n -C3 --type=ts 'resolveStartupRecoveryAction|open-empty-storage-menu|recoveryPromptAttempted|pendingRecoveryState|suppressRecoveryPrompt|Startup recovery scan failed|Startup recovery prompt failed' test echo echo "== backup manager result branches in tests ==" rg -n -C3 --type=ts 'runBackupRestoreManager|restored|dismissed|failed' test/recovery.test.ts test/codex-manager-cli.test.tsas 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: 4064-4144
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lib/codex-manager.ts` around lines 3840 - 3851, Add vitest unit tests to cover every branch of the new startup recovery state machine: exercise resolveStartupRecoveryAction with recoveryState.assessments > 0, recoveryScanFailed true, totalBackups > 0, and the default open-empty-storage-menu case; add tests that simulate prompt flow toggles (pendingRecoveryState, recoveryPromptAttempted, suppressRecoveryPrompt) both before and after the prompt is shown and when the prompt fails; add tests that stub/mock runBackupRestoreManager to return restored, dismissed, and failed outcomes and assert the manager result correctly resets/updates state (e.g., pendingRecoveryState cleared, suppressRecoveryPrompt set/unset); put these tests in test/recovery.test.ts (or adjacent vitest files), using the same symbols referenced in the diff (resolveStartupRecoveryAction, pendingRecoveryState, recoveryPromptAttempted, suppressRecoveryPrompt, runBackupRestoreManager, restored, dismissed, failed) and ensure you assert transitions for the scan-failure and open-empty-menu routing paths.
4387-4413:⚠️ Potential issue | 🟠 Majorcover transient filesystem behavior for the backup assessment queue with vitest.
line [4393] batches restore assessments concurrently. this path should have explicit tests for transient
EBUSY/EPERMand terminal failure behavior so queue semantics stay stable across windows io edge cases. refs:lib/codex-manager.ts:4387,lib/codex-manager.ts:4393,test/recovery.test.ts:1.#!/bin/bash set -euo pipefail echo "== queue + fs transient handling in source ==" rg -n -C3 --type=ts 'NAMED_BACKUP_LIST_CONCURRENCY|Promise.allSettled|assessNamedBackupRestore|EBUSY|EPERM|EAGAIN' lib/codex-manager.ts lib/storage.ts echo echo "== corresponding vitest coverage ==" rg -n -C3 --type=ts 'EBUSY|EPERM|EAGAIN|retryTransientFilesystemOperation|assessNamedBackupRestore|loadBackupRestoreManagerAssessments' testas 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/codex-manager.ts` around lines 4387 - 4413, Add vitest coverage that simulates transient Windows FS errors and terminal failures for the concurrent batch path that uses NAMED_BACKUP_LIST_CONCURRENCY and Promise.allSettled in loadBackupRestoreManagerAssessments/assessNamedBackupRestore: write tests in test/recovery.test.ts that stub/mock the underlying filesystem calls (or stub assessNamedBackupRestore) to first throw transient errors (EBUSY/EPERM/EAGAIN) and then succeed, asserting the batch retry semantics allow the assessment to eventually be pushed into assessments, and a separate test where the stub throws a terminal error ensuring the code logs/skips the item (matching the console.warn branch); use vitest vi.fn/mocks and, if code uses retryTransientFilesystemOperation, assert it is invoked, otherwise assert behavior by controlling timing and promises so the Promise.allSettled loop handles transient vs terminal cases correctly.
🤖 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 3030-3039: The code currently calls deduplicateAccounts(merged)
twice in the import path (once inside the limit check and again when assigning
deduplicatedAccounts); compute dedupe once and reuse it: call const deduplicated
= deduplicateAccounts(merged) before the ACCOUNT_LIMITS.MAX_ACCOUNTS check, use
deduplicated.length in the guard and then assign deduplicatedAccounts =
deduplicated (or rename consistently) so you avoid redundant work on large
imports and keep references to merged, deduplicateAccounts, and
ACCOUNT_LIMITS.MAX_ACCOUNTS intact.
- Around line 1779-1806: Add Windows-specific tests that mock process.platform =
"win32" and assert that retryTransientFilesystemOperation actually retries on
EPERM and EBUSY: for each code, create a fake operation that throws a
NodeJS.ErrnoException with .code set to "EPERM" or "EBUSY" for the first N
attempts then resolves, call retryTransientFilesystemOperation and assert it
eventually returns and that the fake was called multiple times; restore
process.platform after each test. Use the same pattern to keep the Linux tests
(process.platform !== "win32") asserting EPERM/EBUSY rethrow immediately, and
reference the functions isRetryableFilesystemErrorCode and
retryTransientFilesystemOperation when locating the code under test.
In `@test/recovery.test.ts`:
- Around line 380-453: The test "keeps actionable backups when fast-path scan
hits EBUSY" relies on platform-specific EBUSY handling; wrap the part that calls
getActionableNamedBackupRestores with an explicit process.platform override
(save original, use Object.defineProperty to set process.platform to 'win32' for
the Windows behavior the test expects, then restore original afterward) so the
expectations remain deterministic; apply the same pattern to the other related
tests mentioned (the ones calling getActionableNamedBackupRestores and asserting
EBUSY behavior) to ensure consistent platform-specific behavior.
- Around line 783-815: Add regression tests for resolveStartupRecoveryAction to
cover the missing branches: call resolveStartupRecoveryAction with an input
object where assessments contains at least one actionable assessment (e.g.,
assessments: [{ actionable: true }]) and totalBackups > 0 and scanFailed = false
and assert it returns the recovery prompt route (the code path currently implied
by "open-empty-storage-menu"/recovery prompt); then add a test where assessments
contains an actionable item but scanning is suppressed (pass the
suppression/scanFailed flag that triggers same-session suppression) and assert
it returns "continue-with-oauth". Keep the existing tests for zero backups/no
scan error as-is. Reference resolveStartupRecoveryAction and the input shape
(assessments, totalBackups, scanFailed) when adding the two new cases.
In `@test/storage.test.ts`:
- Around line 1605-1617: The two tests are ambiguous about platform: ensure the
non-Windows test for EBUSY retry uses a mocked process.platform returning
"linux" and the Windows-retry test uses process.platform returning "win32" so
behavior is deterministic; in the non-Windows case (test around
listNamedBackups) keep vi.spyOn(process, "platform",
"get").mockReturnValue("linux") and assert readdir was called once, and in the
separate Windows-retry test (lines ~1638-1678) mock process.platform to "win32"
and set readdir to initially reject with an EBUSY error then succeed on retry to
assert retry behavior; also restore/clear spies/mocks after each test to avoid
cross-test leakage.
---
Duplicate comments:
In `@lib/codex-manager.ts`:
- Around line 3840-3851: Add vitest unit tests to cover every branch of the new
startup recovery state machine: exercise resolveStartupRecoveryAction with
recoveryState.assessments > 0, recoveryScanFailed true, totalBackups > 0, and
the default open-empty-storage-menu case; add tests that simulate prompt flow
toggles (pendingRecoveryState, recoveryPromptAttempted, suppressRecoveryPrompt)
both before and after the prompt is shown and when the prompt fails; add tests
that stub/mock runBackupRestoreManager to return restored, dismissed, and failed
outcomes and assert the manager result correctly resets/updates state (e.g.,
pendingRecoveryState cleared, suppressRecoveryPrompt set/unset); put these tests
in test/recovery.test.ts (or adjacent vitest files), using the same symbols
referenced in the diff (resolveStartupRecoveryAction, pendingRecoveryState,
recoveryPromptAttempted, suppressRecoveryPrompt, runBackupRestoreManager,
restored, dismissed, failed) and ensure you assert transitions for the
scan-failure and open-empty-menu routing paths.
- Around line 4387-4413: Add vitest coverage that simulates transient Windows FS
errors and terminal failures for the concurrent batch path that uses
NAMED_BACKUP_LIST_CONCURRENCY and Promise.allSettled in
loadBackupRestoreManagerAssessments/assessNamedBackupRestore: write tests in
test/recovery.test.ts that stub/mock the underlying filesystem calls (or stub
assessNamedBackupRestore) to first throw transient errors (EBUSY/EPERM/EAGAIN)
and then succeed, asserting the batch retry semantics allow the assessment to
eventually be pushed into assessments, and a separate test where the stub throws
a terminal error ensuring the code logs/skips the item (matching the
console.warn branch); use vitest vi.fn/mocks and, if code uses
retryTransientFilesystemOperation, assert it is invoked, otherwise assert
behavior by controlling timing and promises so the Promise.allSettled loop
handles transient vs terminal cases correctly.
🪄 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: fad95d8e-47a4-43f3-a7eb-b8866b1fb048
📒 Files selected for processing (7)
lib/cli.tslib/codex-manager.tslib/storage.tslib/ui/copy.tstest/codex-manager-cli.test.tstest/recovery.test.tstest/storage.test.ts
💤 Files with no reviewable changes (1)
- lib/ui/copy.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/cli.tslib/codex-manager.tslib/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/recovery.test.tstest/storage.test.ts
🔇 Additional comments (3)
lib/cli.ts (1)
29-31: good consolidation of interactive gating.this keeps tty/non-interactive checks centralized and makes menu fallback behavior predictable. refs:
lib/cli.ts:29,lib/cli.ts:258.Also applies to: 258-260
lib/codex-manager.ts (1)
4510-4529: good fix: restore path now syncs codex active account.post-restore sync closes the direct-restore drift between multi-auth state and codex cli state. refs:
lib/codex-manager.ts:4510,lib/codex-manager.ts:4515.lib/storage.ts (1)
2995-3005: nice hardening on import file read/enoent handling.this correctly translates read-time
ENOENTinto a stable user-facing error and avoids pre-check race issues. refs:lib/storage.ts:2995,lib/storage.ts:3001.
| function isRetryableFilesystemErrorCode( | ||
| code: string | undefined, | ||
| ): code is "EPERM" | "EBUSY" | "EAGAIN" { | ||
| if (code === "EAGAIN") { | ||
| return true; | ||
| } | ||
| if (process.platform !== "win32") { | ||
| return false; | ||
| } | ||
| return code === "EPERM" || code === "EBUSY"; | ||
| return (code === "EPERM" || code === "EBUSY") && process.platform === "win32"; | ||
| } | ||
|
|
||
| async function retryTransientFilesystemOperation<T>( | ||
| operation: () => Promise<T>, | ||
| ): Promise<T> { | ||
| let attempt = 0; | ||
| while (true) { | ||
| for (let attempt = 0; attempt < 5; attempt += 1) { | ||
| try { | ||
| return await operation(); | ||
| } catch (error) { | ||
| const code = (error as NodeJS.ErrnoException).code; | ||
| if ( | ||
| !isRetryableFilesystemErrorCode(code) || | ||
| attempt >= TRANSIENT_FILESYSTEM_MAX_ATTEMPTS - 1 | ||
| ) { | ||
| if (!isRetryableFilesystemErrorCode(code) || attempt === 4) { | ||
| throw error; | ||
| } | ||
| const baseDelayMs = TRANSIENT_FILESYSTEM_BASE_DELAY_MS * 2 ** attempt; | ||
| const jitterMs = Math.floor(Math.random() * baseDelayMs); | ||
| await new Promise((resolve) => | ||
| setTimeout(resolve, baseDelayMs + jitterMs), | ||
| setTimeout(resolve, 10 * 2 ** attempt + Math.floor(Math.random() * 10)), | ||
| ); | ||
| } | ||
| attempt += 1; | ||
| } | ||
|
|
||
| throw new Error("Retry loop exhausted unexpectedly"); | ||
| } |
There was a problem hiding this comment.
❓ Verification inconclusive
Script executed:
#!/bin/bash
set -euo pipefail
echo "== retry policy implementation =="
rg -n -C3 --type=ts 'isRetryableFilesystemErrorCode|retryTransientFilesystemOperation|EPERM|EBUSY|EAGAIN|process.platform' lib/storage.ts
echo
echo "== retry policy tests =="
rg -n -C3 --type=ts 'retryTransientFilesystemOperation|EPERM|EBUSY|EAGAIN|win32|process.platform' testRepository: ndycode/codex-multi-auth
Repository: ndycode/codex-multi-auth
Exit code: 0
stdout:
== retry policy implementation ==
239-export function formatStorageErrorHint(error: unknown, path: string): string {
240- const err = error as NodeJS.ErrnoException;
241- const code = err?.code || "UNKNOWN";
242: const isWindows = process.platform === "win32";
243-
244- switch (code) {
245- case "EACCES":
246: case "EPERM":
247- return isWindows
248- ? `Permission denied writing to ${path}. Check antivirus exclusions for this folder. Ensure you have write permissions.`
249- : `Permission denied writing to ${path}. Check folder permissions. Try: chmod 755 ~/.codex`;
250: case "EBUSY":
251- return `File is locked at ${path}. The file may be open in another program. Close any editors or processes accessing it.`;
252- case "ENOSPC":
253- return `Disk is full. Free up space and try again. Path: ${path}`;
--
287- if (code === "ENOENT") {
288- return;
289- }
290: if ((code === "EPERM" || code === "EBUSY" || code === "EAGAIN") && attempt < 4) {
291- await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt));
292- continue;
293- }
--
466- return;
467- }
468- const canRetry =
469: (code === "EPERM" || code === "EBUSY") &&
470- attempt + 1 < BACKUP_COPY_MAX_ATTEMPTS;
471- if (canRetry) {
472- await new Promise((resolve) =>
--
490- } catch (error) {
491- const code = (error as NodeJS.ErrnoException).code;
492- const canRetry =
493: (code === "EPERM" || code === "EBUSY" || code === "EAGAIN") &&
494- attempt + 1 < BACKUP_COPY_MAX_ATTEMPTS;
495- if (!canRetry) {
496- throw error;
--
1651-async function scanNamedBackups(): Promise<NamedBackupScanResult> {
1652- const backupRoot = getNamedBackupRoot(getStoragePath());
1653- try {
1654: const entries = await retryTransientFilesystemOperation(() =>
1655- fs.readdir(backupRoot, { withFileTypes: true }),
1656- );
1657- const backupEntries = entries
--
1720-async function listNamedBackupsWithoutLoading(): Promise<NamedBackupMetadataListingResult> {
1721- const backupRoot = getNamedBackupRoot(getStoragePath());
1722- try {
1723: const entries = await retryTransientFilesystemOperation(() =>
1724- fs.readdir(backupRoot, { withFileTypes: true }),
1725- );
1726- const backups: NamedBackupMetadata[] = [];
--
1776- return scanResult.backups.map((entry) => entry.backup);
1777-}
1778-
1779:function isRetryableFilesystemErrorCode(
1780- code: string | undefined,
1781:): code is "EPERM" | "EBUSY" | "EAGAIN" {
1782: if (code === "EAGAIN") {
1783- return true;
1784- }
1785: return (code === "EPERM" || code === "EBUSY") && process.platform === "win32";
1786-}
1787-
1788:async function retryTransientFilesystemOperation<T>(
1789- operation: () => Promise<T>,
1790-): Promise<T> {
1791- for (let attempt = 0; attempt < 5; attempt += 1) {
--
1793- return await operation();
1794- } catch (error) {
1795- const code = (error as NodeJS.ErrnoException).code;
1796: if (!isRetryableFilesystemErrorCode(code) || attempt === 4) {
1797- throw error;
1798- }
1799- await new Promise((resolve) =>
--
2022-
2023-async function loadBackupCandidate(path: string): Promise<LoadedBackupCandidate> {
2024- try {
2025: return await retryTransientFilesystemOperation(() =>
2026- loadAccountsFromPath(path),
2027- );
2028- } catch (error) {
--
2036-}
2037-
2038-function equalsNamedBackupEntry(left: string, right: string): boolean {
2039: return process.platform === "win32"
2040- ? left.toLowerCase() === right.toLowerCase()
2041- : left === right;
2042-}
--
2062- const requestedBaseName = stripNamedBackupJsonExtension(requestedWithExtension);
2063-
2064- try {
2065: const entries = await retryTransientFilesystemOperation(() =>
2066- fs.readdir(backupRoot, { withFileTypes: true }),
2067- );
2068- for (const entry of entries) {
--
2348- ctimeMs?: number;
2349- } | null = null;
2350- try {
2351: stats = await retryTransientFilesystemOperation(() => fs.stat(path));
2352- } catch (error) {
2353- const code = (error as NodeJS.ErrnoException).code;
2354- if (code !== "ENOENT") {
--
2943- const transactionState = transactionSnapshotContext.getStore();
2944- const currentStoragePath = getStoragePath();
2945- const storage = transactionState?.active
2946: ? (process.platform === "win32"
2947- ? transactionState.storagePath?.toLowerCase() ===
2948- currentStoragePath?.toLowerCase()
2949- : transactionState.storagePath === currentStoragePath)
--
2994-
2995- let content: string;
2996- try {
2997: content = await retryTransientFilesystemOperation(() =>
2998- fs.readFile(resolvedPath, "utf-8"),
2999- );
3000- } catch (error) {
== retry policy tests ==
test/test-model-matrix-script.test.ts-188- it("filters non-path where output on Windows", async () => {
test/test-model-matrix-script.test.ts-189- const platformSpy = vi
test/test-model-matrix-script.test.ts-190- .spyOn(process, "platform", "get")
test/test-model-matrix-script.test.ts:191: .mockReturnValue("win32");
test/test-model-matrix-script.test.ts-192- try {
test/test-model-matrix-script.test.ts-193- spawnSync.mockReturnValue({
test/test-model-matrix-script.test.ts-194- stdout:
--
test/test-model-matrix-script.test.ts-210- it("returns fallback command when where has no executable candidates", async () => {
test/test-model-matrix-script.test.ts-211- const platformSpy = vi
test/test-model-matrix-script.test.ts-212- .spyOn(process, "platform", "get")
test/test-model-matrix-script.test.ts:213: .mockReturnValue("win32");
test/test-model-matrix-script.test.ts-214- try {
test/test-model-matrix-script.test.ts-215- spawnSync.mockReturnValue({
test/test-model-matrix-script.test.ts-216- stdout: "",
--
test/test-model-matrix-script.test.ts-231- it("serializes stopCodexServers calls and kills only tracked Windows PIDs", async () => {
test/test-model-matrix-script.test.ts-232- const platformSpy = vi
test/test-model-matrix-script.test.ts-233- .spyOn(process, "platform", "get")
test/test-model-matrix-script.test.ts:234: .mockReturnValue("win32");
test/test-model-matrix-script.test.ts-235- try {
test/test-model-matrix-script.test.ts-236- spawnSync.mockReturnValue({ status: 0, stdout: "", stderr: "" });
test/test-model-matrix-script.test.ts-237-
--
test/unified-settings.test.ts-123- const renameSpy = vi.spyOn(fs, "rename");
test/unified-settings.test.ts-124- renameSpy.mockImplementationOnce(async () => {
test/unified-settings.test.ts-125- const error = new Error("busy") as NodeJS.ErrnoException;
test/unified-settings.test.ts:126: error.code = "EBUSY";
test/unified-settings.test.ts-127- throw error;
test/unified-settings.test.ts-128- });
test/unified-settings.test.ts-129- try {
--
test/unified-settings.test.ts-167- ).toBe("");
test/unified-settings.test.ts-168- });
test/unified-settings.test.ts-169-
test/unified-settings.test.ts:170: it("retries async rename on windows-style EPERM lock", async () => {
test/unified-settings.test.ts-171- const { saveUnifiedPluginConfig, loadUnifiedPluginConfigSync } =
test/unified-settings.test.ts-172- await import("../lib/unified-settings.js");
test/unified-settings.test.ts-173- const renameSpy = vi.spyOn(fs, "rename");
test/unified-settings.test.ts-174- renameSpy.mockImplementationOnce(async () => {
test/unified-settings.test.ts-175- const error = new Error("perm") as NodeJS.ErrnoException;
test/unified-settings.test.ts:176: error.code = "EPERM";
test/unified-settings.test.ts-177- throw error;
test/unified-settings.test.ts-178- });
test/unified-settings.test.ts-179- try {
--
test/unified-settings.test.ts-188- }
test/unified-settings.test.ts-189- });
test/unified-settings.test.ts-190-
test/unified-settings.test.ts:191: it("cleans async temp file when rename repeatedly fails with EPERM", async () => {
test/unified-settings.test.ts-192- const { saveUnifiedPluginConfig, getUnifiedSettingsPath } = await import(
test/unified-settings.test.ts-193- "../lib/unified-settings.js"
test/unified-settings.test.ts-194- );
test/unified-settings.test.ts-195- const renameSpy = vi.spyOn(fs, "rename");
test/unified-settings.test.ts-196- renameSpy.mockImplementation(async () => {
test/unified-settings.test.ts-197- const error = new Error("perm locked") as NodeJS.ErrnoException;
test/unified-settings.test.ts:198: error.code = "EPERM";
test/unified-settings.test.ts-199- throw error;
test/unified-settings.test.ts-200- });
test/unified-settings.test.ts-201- try {
--
test/unified-settings.test.ts-395- const readSpy = vi.spyOn(fs, "readFile");
test/unified-settings.test.ts-396- readSpy.mockImplementationOnce(async () => {
test/unified-settings.test.ts-397- const error = new Error("file locked") as NodeJS.ErrnoException;
test/unified-settings.test.ts:398: error.code = "EBUSY";
test/unified-settings.test.ts-399- throw error;
test/unified-settings.test.ts-400- });
test/unified-settings.test.ts-401-
--
test/storage-flagged.test.ts-12- setStoragePathDirect,
test/storage-flagged.test.ts-13-} from "../lib/storage.js";
test/storage-flagged.test.ts-14-
test/storage-flagged.test.ts:15:const RETRYABLE_REMOVE_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]);
test/storage-flagged.test.ts-16-
test/storage-flagged.test.ts-17-async function removeWithRetry(
test/storage-flagged.test.ts-18- targetPath: string,
--
test/storage-flagged.test.ts-232- expect(flagged.accounts).toHaveLength(0);
test/storage-flagged.test.ts-233- });
test/storage-flagged.test.ts-234-
test/storage-flagged.test.ts:235: it.each(["EPERM", "EBUSY"] as const)(
test/storage-flagged.test.ts-236- "retries transient %s when clearing flagged storage",
test/storage-flagged.test.ts-237- async (code) => {
test/storage-flagged.test.ts-238- await saveFlaggedAccounts({
--
test/storage-flagged.test.ts-276- },
test/storage-flagged.test.ts-277- );
test/storage-flagged.test.ts-278-
test/storage-flagged.test.ts:279: it.each(["EPERM", "EBUSY"] as const)(
test/storage-flagged.test.ts-280- "returns false when clearing flagged storage exhausts retryable %s failures",
test/storage-flagged.test.ts-281- async (code) => {
test/storage-flagged.test.ts-282- await saveFlaggedAccounts({
--
test/storage-flagged.test.ts-354- const [targetPath] = args;
test/storage-flagged.test.ts-355- if (targetPath === flaggedPath) {
test/storage-flagged.test.ts-356- const error = new Error(
test/storage-flagged.test.ts:357: "EPERM flagged read",
test/storage-flagged.test.ts-358- ) as NodeJS.ErrnoException;
test/storage-flagged.test.ts:359: error.code = "EPERM";
test/storage-flagged.test.ts-360- throw error;
test/storage-flagged.test.ts-361- }
test/storage-flagged.test.ts-362- return originalReadFile(...args);
--
test/storage.test.ts-711- flaggedRenameAttempts += 1;
test/storage.test.ts-712- const error = Object.assign(
test/storage.test.ts-713- new Error("flagged storage busy"),
test/storage.test.ts:714: { code: "EBUSY" },
test/storage.test.ts-715- );
test/storage.test.ts-716- throw error;
test/storage.test.ts-717- }
--
test/storage.test.ts-822- flaggedRenameAttempts += 1;
test/storage.test.ts-823- const error = Object.assign(
test/storage.test.ts-824- new Error("flagged storage busy"),
test/storage.test.ts:825: { code: "EBUSY" },
test/storage.test.ts-826- );
test/storage.test.ts-827- throw error;
test/storage.test.ts-828- }
--
test/storage.test.ts-831- if (accountRenameAttempts > 1) {
test/storage.test.ts-832- const error = Object.assign(
test/storage.test.ts-833- new Error("rollback account storage busy"),
test/storage.test.ts:834: { code: "EBUSY" },
test/storage.test.ts-835- );
test/storage.test.ts-836- throw error;
test/storage.test.ts-837- }
--
test/storage.test.ts-939- if (flaggedRenameAttempts <= 2) {
test/storage.test.ts-940- const error = Object.assign(
test/storage.test.ts-941- new Error("flagged storage busy"),
test/storage.test.ts:942: { code: "EBUSY" },
test/storage.test.ts-943- );
test/storage.test.ts-944- throw error;
test/storage.test.ts-945- }
--
test/storage.test.ts-1063- if (String(path) === exportPath && busyFailures === 0) {
test/storage.test.ts-1064- busyFailures += 1;
test/storage.test.ts-1065- const error = new Error("import file busy") as NodeJS.ErrnoException;
test/storage.test.ts:1066: error.code = "EAGAIN";
test/storage.test.ts-1067- throw error;
test/storage.test.ts-1068- }
test/storage.test.ts-1069- return originalReadFile(...(args as Parameters<typeof fs.readFile>));
--
test/storage.test.ts-1574- it("rethrows unreadable backup directory errors while listing backups", async () => {
test/storage.test.ts-1575- const readdirSpy = vi.spyOn(fs, "readdir");
test/storage.test.ts-1576- const error = new Error("backup directory locked") as NodeJS.ErrnoException;
test/storage.test.ts:1577: error.code = "EPERM";
test/storage.test.ts-1578- readdirSpy.mockRejectedValue(error);
test/storage.test.ts-1579-
test/storage.test.ts-1580- try {
test/storage.test.ts:1581: await expect(listNamedBackups()).rejects.toMatchObject({ code: "EPERM" });
test/storage.test.ts-1582- } finally {
test/storage.test.ts-1583- readdirSpy.mockRestore();
test/storage.test.ts-1584- }
--
test/storage.test.ts-1590- .mockReturnValue("linux");
test/storage.test.ts-1591- const readdirSpy = vi.spyOn(fs, "readdir");
test/storage.test.ts-1592- const error = new Error("backup directory locked") as NodeJS.ErrnoException;
test/storage.test.ts:1593: error.code = "EPERM";
test/storage.test.ts-1594- readdirSpy.mockRejectedValue(error);
test/storage.test.ts-1595-
test/storage.test.ts-1596- try {
test/storage.test.ts:1597: await expect(listNamedBackups()).rejects.toMatchObject({ code: "EPERM" });
test/storage.test.ts-1598- expect(readdirSpy).toHaveBeenCalledTimes(1);
test/storage.test.ts-1599- } finally {
test/storage.test.ts-1600- readdirSpy.mockRestore();
--
test/storage.test.ts-1602- }
test/storage.test.ts-1603- });
test/storage.test.ts-1604-
test/storage.test.ts:1605: it("rethrows EBUSY backup directory errors after one attempt on non-Windows platforms", async () => {
test/storage.test.ts-1606- const platformSpy = vi
test/storage.test.ts-1607- .spyOn(process, "platform", "get")
test/storage.test.ts-1608- .mockReturnValue("linux");
test/storage.test.ts-1609- const readdirSpy = vi.spyOn(fs, "readdir");
test/storage.test.ts-1610- const error = new Error("backup directory busy") as NodeJS.ErrnoException;
test/storage.test.ts:1611: error.code = "EBUSY";
test/storage.test.ts-1612- readdirSpy.mockRejectedValue(error);
test/storage.test.ts-1613-
test/storage.test.ts-1614- try {
test/storage.test.ts:1615: await expect(listNamedBackups()).rejects.toMatchObject({ code: "EBUSY" });
test/storage.test.ts-1616- expect(readdirSpy).toHaveBeenCalledTimes(1);
test/storage.test.ts-1617- } finally {
test/storage.test.ts-1618- readdirSpy.mockRestore();
--
test/storage.test.ts-1623- it("rethrows unreadable backup directory errors while restoring backups", async () => {
test/storage.test.ts-1624- const readdirSpy = vi.spyOn(fs, "readdir");
test/storage.test.ts-1625- const error = new Error("backup directory locked") as NodeJS.ErrnoException;
test/storage.test.ts:1626: error.code = "EPERM";
test/storage.test.ts-1627- readdirSpy.mockRejectedValue(error);
test/storage.test.ts-1628-
test/storage.test.ts-1629- try {
test/storage.test.ts-1630- await expect(restoreNamedBackup("Manual Backup")).rejects.toMatchObject({
test/storage.test.ts:1631: code: "EPERM",
test/storage.test.ts-1632- });
test/storage.test.ts-1633- } finally {
test/storage.test.ts-1634- readdirSpy.mockRestore();
--
test/storage.test.ts-1661- const error = new Error(
test/storage.test.ts-1662- "backup directory busy",
test/storage.test.ts-1663- ) as NodeJS.ErrnoException;
test/storage.test.ts:1664: error.code = "EBUSY";
test/storage.test.ts-1665- throw error;
test/storage.test.ts-1666- }
test/storage.test.ts-1667- return originalReaddir(...(args as Parameters<typeof fs.readdir>));
--
test/storage.test.ts-1707- const error = new Error(
test/storage.test.ts-1708- "backup directory busy",
test/storage.test.ts-1709- ) as NodeJS.ErrnoException;
test/storage.test.ts:1710: error.code = "EAGAIN";
test/storage.test.ts-1711- throw error;
test/storage.test.ts-1712- }
test/storage.test.ts-1713- return originalReaddir(...(args as Parameters<typeof fs.readdir>));
--
test/storage.test.ts-1780- if (String(path) === backup.path && busyFailures === 0) {
test/storage.test.ts-1781- busyFailures += 1;
test/storage.test.ts-1782- const error = new Error("backup file busy") as NodeJS.ErrnoException;
test/storage.test.ts:1783: error.code = "EBUSY";
test/storage.test.ts-1784- throw error;
test/storage.test.ts-1785- }
test/storage.test.ts-1786- return originalReadFile(...(args as Parameters<typeof fs.readFile>));
--
test/storage.test.ts-1820- if (String(path) === backup.path && busyFailures === 0) {
test/storage.test.ts-1821- busyFailures += 1;
test/storage.test.ts-1822- const error = new Error("backup stat busy") as NodeJS.ErrnoException;
test/storage.test.ts:1823: error.code = "EAGAIN";
test/storage.test.ts-1824- throw error;
test/storage.test.ts-1825- }
test/storage.test.ts-1826- return originalStat(...(args as Parameters<typeof fs.stat>));
--
test/storage.test.ts-2057- const testPath = "/home/user/.codex/accounts.json";
test/storage.test.ts-2058-
test/storage.test.ts-2059- it("should return permission hint for EACCES on Windows", () => {
test/storage.test.ts:2060: const originalPlatform = process.platform;
test/storage.test.ts:2061: Object.defineProperty(process, "platform", { value: "win32" });
test/storage.test.ts-2062-
test/storage.test.ts-2063- const err = { code: "EACCES" } as NodeJS.ErrnoException;
test/storage.test.ts-2064- const hint = formatStorageErrorHint(err, testPath);
--
test/storage.test.ts-2070- });
test/storage.test.ts-2071-
test/storage.test.ts-2072- it("should return chmod hint for EACCES on Unix", () => {
test/storage.test.ts:2073: const originalPlatform = process.platform;
test/storage.test.ts-2074- Object.defineProperty(process, "platform", { value: "darwin" });
test/storage.test.ts-2075-
test/storage.test.ts-2076- const err = { code: "EACCES" } as NodeJS.ErrnoException;
--
test/storage.test.ts-2082- Object.defineProperty(process, "platform", { value: originalPlatform });
test/storage.test.ts-2083- });
test/storage.test.ts-2084-
test/storage.test.ts:2085: it("should return permission hint for EPERM", () => {
test/storage.test.ts:2086: const err = { code: "EPERM" } as NodeJS.ErrnoException;
test/storage.test.ts-2087- const hint = formatStorageErrorHint(err, testPath);
test/storage.test.ts-2088-
test/storage.test.ts-2089- expect(hint).toContain("Permission denied");
test/storage.test.ts-2090- expect(hint).toContain(testPath);
test/storage.test.ts-2091- });
test/storage.test.ts-2092-
test/storage.test.ts:2093: it("should return file locked hint for EBUSY", () => {
test/storage.test.ts:2094: const err = { code: "EBUSY" } as NodeJS.ErrnoException;
test/storage.test.ts-2095- const hint = formatStorageErrorHint(err, testPath);
test/storage.test.ts-2096-
test/storage.test.ts-2097- expect(hint).toContain("locked");
--
test/storage.test.ts-2725- await expect(clearAccounts()).resolves.not.toThrow();
test/storage.test.ts-2726- });
test/storage.test.ts-2727-
test/storage.test.ts:2728: it.each(["EPERM", "EBUSY", "EAGAIN"] as const)(
test/storage.test.ts-2729- "retries transient %s when clearing saved account artifacts",
test/storage.test.ts-2730- async (code) => {
test/storage.test.ts-2731- await fs.writeFile(testStoragePath, "{}");
--
test/storage.test.ts-2780- await fs.rm(testWorkDir, { recursive: true, force: true });
test/storage.test.ts-2781- });
test/storage.test.ts-2782-
test/storage.test.ts:2783: it.each(["EPERM", "EBUSY"] as const)(
test/storage.test.ts-2784- "retries transient %s when clearing flagged account storage",
test/storage.test.ts-2785- async (code) => {
test/storage.test.ts-2786- const flaggedPath = getFlaggedAccountsPath();
--
test/storage.test.ts-2815- },
test/storage.test.ts-2816- );
test/storage.test.ts-2817-
test/storage.test.ts:2818: it.each(["EPERM", "EBUSY"] as const)(
test/storage.test.ts-2819- "returns false when clearing flagged account storage exhausts retryable %s failures",
test/storage.test.ts-2820- async (code) => {
test/storage.test.ts-2821- const flaggedPath = getFlaggedAccountsPath();
--
test/storage.test.ts-3010- await expect(clearAccounts()).resolves.not.toThrow();
test/storage.test.ts-3011- });
test/storage.test.ts-3012-
test/storage.test.ts:3013: it.each(["EPERM", "EBUSY"] as const)(
test/storage.test.ts-3014- "returns false when clearing saved accounts exhausts retryable %s failures",
test/storage.test.ts-3015- async (code) => {
test/storage.test.ts-3016- await fs.writeFile(testStoragePath, "{}");
--
test/storage.test.ts-3691- });
test/storage.test.ts-3692- });
test/storage.test.ts-3693-
test/storage.test.ts:3694: describe("saveAccounts EPERM/EBUSY retry logic", () => {
test/storage.test.ts-3695- const testWorkDir = join(
test/storage.test.ts-3696- tmpdir(),
test/storage.test.ts-3697- "codex-retry-" + Math.random().toString(36).slice(2),
--
test/storage.test.ts-3711- await fs.rm(testWorkDir, { recursive: true, force: true });
test/storage.test.ts-3712- });
test/storage.test.ts-3713-
test/storage.test.ts:3714: it("retries on EPERM and succeeds on second attempt", async () => {
test/storage.test.ts-3715- const now = Date.now();
test/storage.test.ts-3716- const storage = {
test/storage.test.ts-3717- version: 3 as const,
--
test/storage.test.ts-3726- .mockImplementation(async (oldPath, newPath) => {
test/storage.test.ts-3727- attemptCount++;
test/storage.test.ts-3728- if (attemptCount === 1) {
test/storage.test.ts:3729: const err = new Error("EPERM error") as NodeJS.ErrnoException;
test/storage.test.ts:3730: err.code = "EPERM";
test/storage.test.ts-3731- throw err;
test/storage.test.ts-3732- }
test/storage.test.ts-3733- return originalRename(oldPath as string, newPath as string);
--
test/storage.test.ts-3740- renameSpy.mockRestore();
test/storage.test.ts-3741- });
test/storage.test.ts-3742-
test/storage.test.ts:3743: it("retries on EBUSY and succeeds on third attempt", async () => {
test/storage.test.ts-3744- const now = Date.now();
test/storage.test.ts-3745- const storage = {
test/storage.test.ts-3746- version: 3 as const,
--
test/storage.test.ts-3755- .mockImplementation(async (oldPath, newPath) => {
test/storage.test.ts-3756- attemptCount++;
test/storage.test.ts-3757- if (attemptCount <= 2) {
test/storage.test.ts:3758: const err = new Error("EBUSY error") as NodeJS.ErrnoException;
test/storage.test.ts:3759: err.code = "EBUSY";
test/storage.test.ts-3760- throw err;
test/storage.test.ts-3761- }
test/storage.test.ts-3762- return originalRename(oldPath as string, newPath as string);
--
test/storage.test.ts-3769- renameSpy.mockRestore();
test/storage.test.ts-3770- });
test/storage.test.ts-3771-
test/storage.test.ts:3772: it("retries on EAGAIN and cleans up the WAL after rename succeeds", async () => {
test/storage.test.ts-3773- const now = Date.now();
test/storage.test.ts-3774- const storage = {
test/storage.test.ts-3775- version: 3 as const,
--
test/storage.test.ts-3785- .mockImplementation(async (oldPath, newPath) => {
test/storage.test.ts-3786- attemptCount++;
test/storage.test.ts-3787- if (attemptCount === 1) {
test/storage.test.ts:3788: const err = new Error("EAGAIN error") as NodeJS.ErrnoException;
test/storage.test.ts:3789: err.code = "EAGAIN";
test/storage.test.ts-3790- throw err;
test/storage.test.ts-3791- }
test/storage.test.ts-3792- return originalRename(oldPath as string, newPath as string);
--
test/storage.test.ts-3800- renameSpy.mockRestore();
test/storage.test.ts-3801- });
test/storage.test.ts-3802-
test/storage.test.ts:3803: it("throws after 5 failed EPERM retries", async () => {
test/storage.test.ts-3804- const now = Date.now();
test/storage.test.ts-3805- const storage = {
test/storage.test.ts-3806- version: 3 as const,
--
test/storage.test.ts-3811- let attemptCount = 0;
test/storage.test.ts-3812- const renameSpy = vi.spyOn(fs, "rename").mockImplementation(async () => {
test/storage.test.ts-3813- attemptCount++;
test/storage.test.ts:3814: const err = new Error("EPERM error") as NodeJS.ErrnoException;
test/storage.test.ts:3815: err.code = "EPERM";
test/storage.test.ts-3816- throw err;
test/storage.test.ts-3817- });
test/storage.test.ts-3818-
--
test/storage.test.ts-3824- renameSpy.mockRestore();
test/storage.test.ts-3825- });
test/storage.test.ts-3826-
test/storage.test.ts:3827: it("throws immediately on non-EPERM/EBUSY errors", async () => {
test/storage.test.ts-3828- const now = Date.now();
test/storage.test.ts-3829- const storage = {
test/storage.test.ts-3830- version: 3 as const,
--
test/storage.test.ts-3870- statSpy.mockRestore();
test/storage.test.ts-3871- });
test/storage.test.ts-3872-
test/storage.test.ts:3873: it("retries backup copyFile on transient EBUSY and succeeds", async () => {
test/storage.test.ts-3874- const now = Date.now();
test/storage.test.ts-3875- const storage = {
test/storage.test.ts-3876- version: 3 as const,
--
test/storage.test.ts-3888- .mockImplementation(async (src, dest) => {
test/storage.test.ts-3889- copyAttempts += 1;
test/storage.test.ts-3890- if (copyAttempts === 1) {
test/storage.test.ts:3891: const err = new Error("EBUSY copy") as NodeJS.ErrnoException;
test/storage.test.ts:3892: err.code = "EBUSY";
test/storage.test.ts-3893- throw err;
test/storage.test.ts-3894- }
test/storage.test.ts-3895- return originalCopy(src as string, dest as string);
--
test/storage.test.ts-3908- }
test/storage.test.ts-3909- });
test/storage.test.ts-3910-
test/storage.test.ts:3911: it("retries backup copyFile on transient EPERM and succeeds", async () => {
test/storage.test.ts-3912- const now = Date.now();
test/storage.test.ts-3913- const storage = {
test/storage.test.ts-3914- version: 3 as const,
--
test/storage.test.ts-3926- .mockImplementation(async (src, dest) => {
test/storage.test.ts-3927- copyAttempts += 1;
test/storage.test.ts-3928- if (copyAttempts === 1) {
test/storage.test.ts:3929: const err = new Error("EPERM copy") as NodeJS.ErrnoException;
test/storage.test.ts:3930: err.code = "EPERM";
test/storage.test.ts-3931- throw err;
test/storage.test.ts-3932- }
test/storage.test.ts-3933- return originalCopy(src as string, dest as string);
--
test/storage.test.ts-3946- }
test/storage.test.ts-3947- });
test/storage.test.ts-3948-
test/storage.test.ts:3949: it("retries staged backup rename on transient EBUSY and succeeds", async () => {
test/storage.test.ts-3950- const now = Date.now();
test/storage.test.ts-3951- const storagePath = getStoragePath();
test/storage.test.ts-3952- const storage = {
--
test/storage.test.ts-3968- stagedRenameAttempts += 1;
test/storage.test.ts-3969- if (stagedRenameAttempts === 1) {
test/storage.test.ts-3970- const err = new Error(
test/storage.test.ts:3971: "EBUSY staged rename",
test/storage.test.ts-3972- ) as NodeJS.ErrnoException;
test/storage.test.ts:3973: err.code = "EBUSY";
test/storage.test.ts-3974- throw err;
test/storage.test.ts-3975- }
test/storage.test.ts-3976- }
--
test/storage.test.ts-4299- await expect(clearQuotaCache()).resolves.toBe(true);
test/storage.test.ts-4300- });
test/storage.test.ts-4301-
test/storage.test.ts:4302: it.each(["EPERM", "EBUSY"] as const)(
test/storage.test.ts-4303- "retries transient %s when clearing the quota cache",
test/storage.test.ts-4304- async (code) => {
test/storage.test.ts-4305- const quotaPath = getQuotaCachePath();
--
test/storage.test.ts-4328- },
test/storage.test.ts-4329- );
test/storage.test.ts-4330-
test/storage.test.ts:4331: it.each(["EPERM", "EBUSY"] as const)(
test/storage.test.ts-4332- "returns false when quota-cache clear exhausts retryable %s failures",
test/storage.test.ts-4333- async (code) => {
test/storage.test.ts-4334- const quotaPath = getQuotaCachePath();
--
test/settings-hub-utils.test.ts-235- attempts += 1;
test/settings-hub-utils.test.ts-236- if (attempts < 3) {
test/settings-hub-utils.test.ts-237- const error = new Error("busy") as NodeJS.ErrnoException;
test/settings-hub-utils.test.ts:238: error.code = attempts === 1 ? "EBUSY" : "EPERM";
test/settings-hub-utils.test.ts-239- throw error;
test/settings-hub-utils.test.ts-240- }
test/settings-hub-utils.test.ts-241- return "ok";
--
test/settings-hub-utils.test.ts-244- expect(attempts).toBe(3);
test/settings-hub-utils.test.ts-245- });
test/settings-hub-utils.test.ts-246-
test/settings-hub-utils.test.ts:247: it("retries queued writes for EAGAIN filesystem errors", async () => {
test/settings-hub-utils.test.ts-248- const api = await loadSettingsHubTestApi();
test/settings-hub-utils.test.ts-249- let attempts = 0;
test/settings-hub-utils.test.ts-250- const result = await api.withQueuedRetry(
--
test/settings-hub-utils.test.ts-253- attempts += 1;
test/settings-hub-utils.test.ts-254- if (attempts < 3) {
test/settings-hub-utils.test.ts-255- const error = new Error("busy") as NodeJS.ErrnoException;
test/settings-hub-utils.test.ts:256: error.code = "EAGAIN";
test/settings-hub-utils.test.ts-257- throw error;
test/settings-hub-utils.test.ts-258- }
test/settings-hub-utils.test.ts-259- return "ok";
--
test/settings-hub-utils.test.ts-662- }));
test/settings-hub-utils.test.ts-663- const nodeFs = await import("node:fs");
test/settings-hub-utils.test.ts-664- const busyError = new Error("busy") as NodeJS.ErrnoException;
test/settings-hub-utils.test.ts:665: busyError.code = "EBUSY";
test/settings-hub-utils.test.ts-666- const readSpy = vi
test/settings-hub-utils.test.ts-667- .spyOn(nodeFs.promises, "readFile")
test/settings-hub-utils.test.ts-668- .mockRejectedValueOnce(busyError)
--
test/rotation-integration.test.ts-15-let testStoragePath: string;
test/rotation-integration.test.ts-16-
test/rotation-integration.test.ts-17-async function removeWithRetry(path: string, options: { recursive?: boolean; force?: boolean }): Promise<void> {
test/rotation-integration.test.ts:18: const retryableCodes = new Set(["ENOTEMPTY", "EPERM", "EBUSY"]);
test/rotation-integration.test.ts-19- const maxAttempts = 6;
test/rotation-integration.test.ts-20-
test/rotation-integration.test.ts-21- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
--
test/release-main-prs-regression.test.ts-192- const originalUnlink = fs.unlink.bind(fs);
test/release-main-prs-regression.test.ts-193- const unlinkSpy = vi.spyOn(fs, "unlink").mockImplementation(async (targetPath) => {
test/release-main-prs-regression.test.ts-194- if (targetPath === flaggedPath) {
test/release-main-prs-regression.test.ts:195: const error = new Error("EPERM primary delete") as NodeJS.ErrnoException;
test/release-main-prs-regression.test.ts:196: error.code = "EPERM";
test/release-main-prs-regression.test.ts-197- throw error;
test/release-main-prs-regression.test.ts-198- }
test/release-main-prs-regression.test.ts-199- return originalUnlink(targetPath);
--
test/repo-hygiene.test.ts-7-import { execFileSync, spawn, spawnSync } from "node:child_process";
test/repo-hygiene.test.ts-8-
test/repo-hygiene.test.ts-9-async function removeWithRetry(targetPath: string, options: { recursive?: boolean; force?: boolean }): Promise<void> {
test/repo-hygiene.test.ts:10: const retryableCodes = new Set(["ENOTEMPTY", "EPERM", "EBUSY"]);
test/repo-hygiene.test.ts-11- const maxAttempts = 6;
test/repo-hygiene.test.ts-12-
test/repo-hygiene.test.ts-13- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
--
test/refresh-queue.test.ts-887- vi.mocked(authModule.refreshAccessToken).mockResolvedValue(mockResult);
test/refresh-queue.test.ts-888-
test/refresh-queue.test.ts-889- const leaseCoordinator = {
test/refresh-queue.test.ts:890: acquire: vi.fn().mockRejectedValue(new Error("EBUSY lease dir")),
test/refresh-queue.test.ts-891- } as unknown as RefreshLeaseCoordinator;
test/refresh-queue.test.ts-892-
test/refresh-queue.test.ts-893- const queue = new RefreshQueue(30_000, leaseCoordinator);
--
test/runtime-paths.test.ts-117- });
test/runtime-paths.test.ts-118-
test/runtime-paths.test.ts-119- it("deduplicates Windows-style fallback paths case-insensitively", async () => {
test/runtime-paths.test.ts:120: const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
test/runtime-paths.test.ts-121- try {
test/runtime-paths.test.ts-122- homedir.mockReturnValue("C:\\Users\\Neil");
test/runtime-paths.test.ts-123- process.env.CODEX_HOME = "C:\\USERS\\NEIL\\.codex";
--
test/runtime-paths.test.ts-136- });
test/runtime-paths.test.ts-137-
test/runtime-paths.test.ts-138- it("treats default Windows CODEX_HOME with a trailing separator as the default root", async () => {
test/runtime-paths.test.ts:139: const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
test/runtime-paths.test.ts-140- try {
test/runtime-paths.test.ts-141- homedir.mockReturnValue("C:\\Users\\Neil");
test/runtime-paths.test.ts-142- process.env.CODEX_HOME = "C:\\Users\\Neil\\.codex\\";
--
test/runtime-paths.test.ts-169- });
test/runtime-paths.test.ts-170-
test/runtime-paths.test.ts-171- it("prefers USERPROFILE over os.homedir on Windows when CODEX_HOME is unset", async () => {
test/runtime-paths.test.ts:172: const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
test/runtime-paths.test.ts-173- try {
test/runtime-paths.test.ts-174- homedir.mockReturnValue("C:\\Windows\\System32\\config\\systemprofile");
test/runtime-paths.test.ts-175- process.env.USERPROFILE = "C:\\Users\\Alice";
--
test/runtime-paths.test.ts-182- });
test/runtime-paths.test.ts-183-
test/runtime-paths.test.ts-184- it("falls back to HOME when USERPROFILE is missing on Windows", async () => {
test/runtime-paths.test.ts:185: const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
test/runtime-paths.test.ts-186- try {
test/runtime-paths.test.ts-187- homedir.mockReturnValue("C:\\Windows\\System32\\config\\systemprofile");
test/runtime-paths.test.ts-188- process.env.HOME = "D:\\Users\\Bob";
--
test/runtime-paths.test.ts-194- });
test/runtime-paths.test.ts-195-
test/runtime-paths.test.ts-196- it("falls back to HOMEDRIVE and HOMEPATH when USERPROFILE and HOME are missing on Windows", async () => {
test/runtime-paths.test.ts:197: const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
test/runtime-paths.test.ts-198- try {
test/runtime-paths.test.ts-199- homedir.mockReturnValue("C:\\Windows\\System32\\config\\systemprofile");
test/runtime-paths.test.ts-200- process.env.HOMEDRIVE = "E:";
--
test/runtime-paths.test.ts-207- });
test/runtime-paths.test.ts-208-
test/runtime-paths.test.ts-209- it("normalizes HOMEPATH without a leading slash on Windows", async () => {
test/runtime-paths.test.ts:210: const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
test/runtime-paths.test.ts-211- try {
test/runtime-paths.test.ts-212- homedir.mockReturnValue("C:\\Windows\\System32\\config\\systemprofile");
test/runtime-paths.test.ts-213- process.env.HOMEDRIVE = "E:";
--
test/refresh-lease.test.ts-121- if (String(path).endsWith(".lock") && busyCount < 2) {
test/refresh-lease.test.ts-122- busyCount += 1;
test/refresh-lease.test.ts-123- const error = new Error("busy") as NodeJS.ErrnoException;
test/refresh-lease.test.ts:124: error.code = "EBUSY";
test/refresh-lease.test.ts-125- throw error;
test/refresh-lease.test.ts-126- }
test/refresh-lease.test.ts-127- return originalUnlink(path);
--
test/refresh-lease.test.ts-173- unlink: vi.fn(async (path: Parameters<typeof fsPromises.unlink>[0]) => {
test/refresh-lease.test.ts-174- if (String(path).endsWith(".lock")) {
test/refresh-lease.test.ts-175- const error = new Error("busy") as NodeJS.ErrnoException;
test/refresh-lease.test.ts:176: error.code = "EBUSY";
test/refresh-lease.test.ts-177- throw error;
test/refresh-lease.test.ts-178- }
test/refresh-lease.test.ts-179- return originalUnlink(path);
--
test/quota-cache.test.ts-105- }
test/quota-cache.test.ts-106- });
test/quota-cache.test.ts-107-
test/quota-cache.test.ts:108: it.each(["EBUSY", "EPERM"] as const)(
test/quota-cache.test.ts-109- "retries transient %s while clearing cache",
test/quota-cache.test.ts-110- async (code) => {
test/quota-cache.test.ts-111- const { clearQuotaCache, getQuotaCachePath, saveQuotaCache } =
--
test/quota-cache.test.ts-139- },
test/quota-cache.test.ts-140- );
test/quota-cache.test.ts-141-
test/quota-cache.test.ts:142: it.each(["EBUSY", "EPERM"] as const)(
test/quota-cache.test.ts-143- "returns false when clearQuotaCache exhausts %s retries",
test/quota-cache.test.ts-144- async (code) => {
test/quota-cache.test.ts-145- vi.resetModules();
--
test/quota-cache.test.ts-183- },
test/quota-cache.test.ts-184- );
test/quota-cache.test.ts-185-
test/quota-cache.test.ts:186: it("retries transient EBUSY while loading cache", async () => {
test/quota-cache.test.ts-187- const { loadQuotaCache, getQuotaCachePath } =
test/quota-cache.test.ts-188- await import("../lib/quota-cache.js");
test/quota-cache.test.ts-189- await fs.writeFile(
--
test/quota-cache.test.ts-212- attempts += 1;
test/quota-cache.test.ts-213- if (attempts === 1) {
test/quota-cache.test.ts-214- const error = new Error("busy") as NodeJS.ErrnoException;
test/quota-cache.test.ts:215: error.code = "EBUSY";
test/quota-cache.test.ts-216- throw error;
test/quota-cache.test.ts-217- }
test/quota-cache.test.ts-218- }
--
test/quota-cache.test.ts-228- }
test/quota-cache.test.ts-229- });
test/quota-cache.test.ts-230-
test/quota-cache.test.ts:231: it.each(["EBUSY", "EPERM"] as const)(
test/quota-cache.test.ts-232- "retries atomic rename on transient %s errors",
test/quota-cache.test.ts-233- async (code) => {
test/quota-cache.test.ts-234- const { saveQuotaCache, loadQuotaCache } =
--
test/quota-cache.test.ts-276- const unlinkSpy = vi.spyOn(fs, "unlink");
test/quota-cache.test.ts-277- renameSpy.mockImplementation(async () => {
test/quota-cache.test.ts-278- const error = new Error("locked") as NodeJS.ErrnoException;
test/quota-cache.test.ts:279: error.code = "EPERM";
test/quota-cache.test.ts-280- throw error;
test/quota-cache.test.ts-281- });
test/quota-cache.test.ts-282-
--
test/recovery-constants.test.ts-3-import { homedir } from "node:os";
test/recovery-constants.test.ts-4-
test/recovery-constants.test.ts-5-describe("recovery/constants.ts", () => {
test/recovery-constants.test.ts:6: const originalPlatform = process.platform;
test/recovery-constants.test.ts-7- const originalAppData = process.env.APPDATA;
test/recovery-constants.test.ts-8- const originalXdgDataHome = process.env.XDG_DATA_HOME;
test/recovery-constants.test.ts-9-
--
test/recovery-constants.test.ts-42-
test/recovery-constants.test.ts-43- describe("getXdgData on Windows", () => {
test/recovery-constants.test.ts-44- it("should use APPDATA when set", async () => {
test/recovery-constants.test.ts:45: Object.defineProperty(process, "platform", { value: "win32" });
test/recovery-constants.test.ts-46- process.env.APPDATA = "C:\\Users\\Test\\AppData\\Roaming";
test/recovery-constants.test.ts-47-
test/recovery-constants.test.ts-48- const { CODEX_STORAGE } = await import("../lib/recovery/constants.js");
--
test/recovery-constants.test.ts-51- });
test/recovery-constants.test.ts-52-
test/recovery-constants.test.ts-53- it("should fallback to AppData/Roaming when APPDATA is not set", async () => {
test/recovery-constants.test.ts:54: Object.defineProperty(process, "platform", { value: "win32" });
test/recovery-constants.test.ts-55- delete process.env.APPDATA;
test/recovery-constants.test.ts-56-
test/recovery-constants.test.ts-57- const { CODEX_STORAGE } = await import("../lib/recovery/constants.js");
--
test/recovery.test.ts-54- } catch (error) {
test/recovery.test.ts-55- const code = (error as NodeJS.ErrnoException).code;
test/recovery.test.ts-56- if (
test/recovery.test.ts:57: (code !== "EBUSY" && code !== "EPERM" && code !== "ENOTEMPTY") ||
test/recovery.test.ts-58- attempt === 4
test/recovery.test.ts-59- ) {
test/recovery.test.ts-60- throw error;
--
test/recovery.test.ts-377- expect(result.assessments[0]?.imported).toBe(1);
test/recovery.test.ts-378- });
test/recovery.test.ts-379-
test/recovery.test.ts:380: it("keeps actionable backups when fast-path scan hits EBUSY", async () => {
test/recovery.test.ts-381- const storage = await import("../lib/storage.js");
test/recovery.test.ts-382- const emptyStorage = {
test/recovery.test.ts-383- version: 3,
--
test/recovery.test.ts-427- const [path] = args;
test/recovery.test.ts-428- if (path === lockedBackup?.path) {
test/recovery.test.ts-429- const error = new Error("resource busy") as NodeJS.ErrnoException;
test/recovery.test.ts:430: error.code = "EBUSY";
test/recovery.test.ts-431- throw error;
test/recovery.test.ts-432- }
test/recovery.test.ts-433- return originalReadFile(...args);
--
test/recovery.test.ts-452- }
test/recovery.test.ts-453- });
test/recovery.test.ts-454-
test/recovery.test.ts:455: it("keeps actionable backups when fast-path metadata stat hits EBUSY", async () => {
test/recovery.test.ts-456- const storage = await import("../lib/storage.js");
test/recovery.test.ts-457- const emptyStorage = {
test/recovery.test.ts-458- version: 3,
--
test/recovery.test.ts-504- normalizedPath.endsWith("/locked-backup.json")
test/recovery.test.ts-505- ) {
test/recovery.test.ts-506- const error = new Error("resource busy") as NodeJS.ErrnoException;
test/recovery.test.ts:507: error.code = "EBUSY";
test/recovery.test.ts-508- throw error;
test/recovery.test.ts-509- }
test/recovery.test.ts-510- return originalStat(...args);
--
test/recovery.test.ts-599- }
test/recovery.test.ts-600- });
test/recovery.test.ts-601-
test/recovery.test.ts:602: it("keeps injected-assessor backups when metadata stat hits EBUSY", async () => {
test/recovery.test.ts-603- const storage = await import("../lib/storage.js");
test/recovery.test.ts-604- const emptyStorage = {
test/recovery.test.ts-605- version: 3,
--
test/recovery.test.ts-655- normalizedPath.endsWith("/locked-backup.json")
test/recovery.test.ts-656- ) {
test/recovery.test.ts-657- const error = new Error("resource busy") as NodeJS.ErrnoException;
test/recovery.test.ts:658: error.code = "EBUSY";
test/recovery.test.ts-659- throw error;
test/recovery.test.ts-660- }
test/recovery.test.ts-661- return originalStat(...args);
--
test/recovery.test.ts-694- }
test/recovery.test.ts-695- });
test/recovery.test.ts-696-
test/recovery.test.ts:697: it("keeps actionable backups when default assessment hits EBUSY", async () => {
test/recovery.test.ts-698- const storage = await import("../lib/storage.js");
test/recovery.test.ts-699- const emptyStorage = {
test/recovery.test.ts-700- version: 3,
--
test/recovery.test.ts-743- const [path] = args;
test/recovery.test.ts-744- if (path === lockedBackup?.path) {
test/recovery.test.ts-745- const error = new Error("resource busy") as NodeJS.ErrnoException;
test/recovery.test.ts:746: error.code = "EBUSY";
test/recovery.test.ts-747- throw error;
test/recovery.test.ts-748- }
test/recovery.test.ts-749- return originalReadFile(...args);
--
test/paths.test.ts-117- process.env.HOME = "C:\\Users\\test";
test/paths.test.ts-118- process.env.USERPROFILE = "C:\\Users\\test";
test/paths.test.ts-119- process.env.CODEX_HOME = "C:\\Users\\test\\.codex";
test/paths.test.ts:120: const primary = path.win32.join("C:\\Users\\test\\.codex", "multi-auth");
test/paths.test.ts:121: const fallback = path.win32.join("C:\\Users\\test", "DevTools", "config", "codex", "multi-auth");
test/paths.test.ts:122: const normalizePath = (input: string) => path.win32.normalize(input.replace(/\//g, "\\"));
test/paths.test.ts-123-
test/paths.test.ts-124- mockedExistsSync.mockImplementation((candidate) => {
test/paths.test.ts-125- if (typeof candidate !== "string") return false;
test/paths.test.ts-126- const normalizedCandidate = normalizePath(candidate);
test/paths.test.ts:127: if (normalizedCandidate === normalizePath(path.win32.join(primary, "settings.json"))) return true;
test/paths.test.ts:128: if (normalizedCandidate === normalizePath(path.win32.join(primary, "openai-codex-accounts.json"))) return false;
test/paths.test.ts:129: if (normalizedCandidate === normalizePath(path.win32.join(primary, "codex-accounts.json"))) return false;
test/paths.test.ts:130: if (normalizedCandidate === normalizePath(path.win32.join(primary, "config.json"))) return false;
test/paths.test.ts:131: if (normalizedCandidate === normalizePath(path.win32.join(primary, "dashboard-settings.json"))) return false;
test/paths.test.ts:132: if (normalizedCandidate === normalizePath(path.win32.join(primary, "projects"))) return false;
test/paths.test.ts:133: return normalizedCandidate === normalizePath(path.win32.join(fallback, "openai-codex-accounts.json"));
test/paths.test.ts-134- });
test/paths.test.ts-135-
test/paths.test.ts-136- try {
--
test/paths.test.ts-207- const sharedRepoRoot = path.resolve("repo");
test/paths.test.ts-208- const sharedGitDir = path.join(sharedRepoRoot, ".git");
test/paths.test.ts-209- const normalize = (value: string) =>
test/paths.test.ts:210: process.platform === "win32" ? value.toLowerCase() : value;
test/paths.test.ts-211-
test/paths.test.ts-212- mockedExistsSync.mockImplementation((candidate) => {
test/paths.test.ts-213- if (typeof candidate !== "string") return false;
--
test/paths.test.ts-253- const sharedRepoRoot = path.resolve("repo");
test/paths.test.ts-254- const sharedGitDir = path.join(sharedRepoRoot, ".git");
test/paths.test.ts-255- const normalize = (value: string) =>
test/paths.test.ts:256: process.platform === "win32" ? value.toLowerCase() : value;
test/paths.test.ts-257-
test/paths.test.ts-258- mockedExistsSync.mockImplementation((candidate) => {
test/paths.test.ts-259- if (typeof candidate !== "string") return false;
--
test/paths.test.ts-291- });
test/paths.test.ts-292-
test/paths.test.ts-293- it("supports Windows-style backslash gitdir pointers", () => {
test/paths.test.ts:294: const projectRoot = path.win32.join("C:\\repo", "worktrees", "pr-8");
test/paths.test.ts:295: const gitEntry = path.win32.join(projectRoot, ".git");
test/paths.test.ts:296: const worktreeGitDir = path.win32.join("C:\\repo", ".git", "worktrees", "pr-8");
test/paths.test.ts:297: const commondirFile = path.win32.join(worktreeGitDir, "commondir");
test/paths.test.ts:298: const gitdirBackRefFile = path.win32.join(worktreeGitDir, "gitdir");
test/paths.test.ts-299- const sharedRepoRoot = "C:\\repo";
test/paths.test.ts:300: const sharedGitDir = path.win32.join(sharedRepoRoot, ".git");
test/paths.test.ts:301: const normalize = (value: string) => path.win32.normalize(value).toLowerCase();
test/paths.test.ts-302-
test/paths.test.ts-303- mockedExistsSync.mockImplementation((candidate) => {
test/paths.test.ts-304- if (typeof candidate !== "string") return false;
--
test/paths.test.ts-325- return "..\\..\\\n";
test/paths.test.ts-326- }
test/paths.test.ts-327- if (normalizedCandidate === normalize(gitdirBackRefFile)) {
test/paths.test.ts:328: return `${path.win32.join(projectRoot, ".git")}\n`;
test/paths.test.ts-329- }
test/paths.test.ts-330- throw new Error(`Unexpected read path: ${String(candidate)}`);
test/paths.test.ts-331- });
--
test/paths.test.ts-337-
test/paths.test.ts-338- it("supports windows UNC gitdir pointers", () => {
test/paths.test.ts-339- const sharedRepoRoot = "\\\\server\\share\\repo";
test/paths.test.ts:340: const projectRoot = path.win32.join(sharedRepoRoot, "worktrees", "pr-unc");
test/paths.test.ts:341: const gitEntry = path.win32.join(projectRoot, ".git");
test/paths.test.ts:342: const worktreeGitDir = path.win32.join(sharedRepoRoot, ".git", "worktrees", "pr-unc");
test/paths.test.ts:343: const commondirFile = path.win32.join(worktreeGitDir, "commondir");
test/paths.test.ts:344: const gitdirBackRefFile = path.win32.join(worktreeGitDir, "gitdir");
test/paths.test.ts:345: const sharedGitDir = path.win32.join(sharedRepoRoot, ".git");
test/paths.test.ts:346: const normalize = (value: string) => path.win32.normalize(value).toLowerCase();
test/paths.test.ts-347-
test/paths.test.ts-348- mockedExistsSync.mockImplementation((candidate) => {
test/paths.test.ts-349- if (typeof candidate !== "string") return false;
--
test/paths.test.ts-371- return "..\\..\\\n";
test/paths.test.ts-372- }
test/paths.test.ts-373- if (normalizedCandidate === normalize(gitdirBackRefFile)) {
test/paths.test.ts:374: return `${path.win32.join(projectRoot, ".git")}\n`;
test/paths.test
... [stdout truncated: output exceeded 50000 characters (92678 total). Please modify your command to reduce output size, for example by:
- Using grep/rg to filter results
- Using head/tail to limit lines
- Using --max-count flag with grep/rg
- Processing output with awk/sed to extract only needed information]
add windows-platform test coverage for EPERM/EBUSY retries in retryTransientFilesystemOperation.
platform-specific tests exist for Linux behavior at test/storage.test.ts:1590–1618 (EPERM/EBUSY rethrow without retry), but windows-specific coverage is missing. lib/storage.ts:1785 narrows EPERM/EBUSY retries to win32 only—this is a meaningful constraint shift from earlier retry logic at lib/storage.ts:290, lib/storage.ts:469, and lib/storage.ts:493 which retried all three codes universally. add explicit tests that mock process.platform = "win32" and verify EPERM/EBUSY are actually retried by the new function, alongside the existing Linux rethrow assertions.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@lib/storage.ts` around lines 1779 - 1806, Add Windows-specific tests that
mock process.platform = "win32" and assert that
retryTransientFilesystemOperation actually retries on EPERM and EBUSY: for each
code, create a fake operation that throws a NodeJS.ErrnoException with .code set
to "EPERM" or "EBUSY" for the first N attempts then resolves, call
retryTransientFilesystemOperation and assert it eventually returns and that the
fake was called multiple times; restore process.platform after each test. Use
the same pattern to keep the Linux tests (process.platform !== "win32")
asserting EPERM/EBUSY rethrow immediately, and reference the functions
isRetryableFilesystemErrorCode and retryTransientFilesystemOperation when
locating the code under test.
| it("keeps actionable backups when fast-path scan hits EBUSY", async () => { | ||
| const storage = await import("../lib/storage.js"); | ||
| const emptyStorage = { | ||
| version: 3, | ||
| activeIndex: 0, | ||
| activeIndexByFamily: { codex: 0 }, | ||
| accounts: [], | ||
| }; | ||
| await storage.saveAccounts({ | ||
| version: 3, | ||
| activeIndex: 0, | ||
| activeIndexByFamily: { codex: 0 }, | ||
| accounts: [ | ||
| { | ||
| email: "locked@example.com", | ||
| refreshToken: "locked-token", | ||
| addedAt: 1, | ||
| lastUsed: 1, | ||
| }, | ||
| ], | ||
| }); | ||
| await storage.createNamedBackup("locked-backup"); | ||
| await storage.saveAccounts({ | ||
| version: 3, | ||
| activeIndex: 0, | ||
| activeIndexByFamily: { codex: 0 }, | ||
| accounts: [ | ||
| { | ||
| email: "valid@example.com", | ||
| refreshToken: "valid-token", | ||
| addedAt: 2, | ||
| lastUsed: 2, | ||
| }, | ||
| ], | ||
| }); | ||
| await storage.createNamedBackup("valid-backup"); | ||
| await storage.saveAccounts(emptyStorage); | ||
|
|
||
| const backups = await storage.listNamedBackups(); | ||
| const lockedBackup = backups.find((backup) => backup.name === "locked-backup"); | ||
| const validBackup = backups.find((backup) => backup.name === "valid-backup"); | ||
| expect(lockedBackup).toBeDefined(); | ||
| expect(validBackup).toBeDefined(); | ||
|
|
||
| const originalReadFile = fs.readFile.bind(fs); | ||
| const readFileSpy = vi.spyOn(fs, "readFile").mockImplementation( | ||
| (async (...args: Parameters<typeof fs.readFile>) => { | ||
| const [path] = args; | ||
| if (path === lockedBackup?.path) { | ||
| const error = new Error("resource busy") as NodeJS.ErrnoException; | ||
| error.code = "EBUSY"; | ||
| throw error; | ||
| } | ||
| return originalReadFile(...args); | ||
| }) as typeof fs.readFile, | ||
| ); | ||
|
|
||
| try { | ||
| const result = await storage.getActionableNamedBackupRestores({ | ||
| currentStorage: emptyStorage, | ||
| }); | ||
|
|
||
| expect(result.totalBackups).toBe(2); | ||
| expect(result.assessments.map((item) => item.backup.name)).toEqual([ | ||
| "valid-backup", | ||
| ]); | ||
| expect(result.assessments[0]?.imported).toBe(1); | ||
| expect(readFileSpy.mock.calls.map(([path]) => path)).toEqual( | ||
| expect.arrayContaining([lockedBackup?.path, validBackup?.path]), | ||
| ); | ||
| } finally { | ||
| readFileSpy.mockRestore(); | ||
| } | ||
| }); |
There was a problem hiding this comment.
gate ebusy fast-path recovery tests to explicit platform behavior
these tests assert EBUSY handling outcomes but do not pin process.platform, so expectations can drift by runtime environment. make the platform explicit for each intended behavior to keep test/recovery.test.ts:380-453, test/recovery.test.ts:455-531, and test/recovery.test.ts:697-779 deterministic.
As per coding guidelines, test/**: tests must stay deterministic and use vitest, and demand regression cases that reproduce windows filesystem behavior.
Also applies to: 455-531, 697-779
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@test/recovery.test.ts` around lines 380 - 453, The test "keeps actionable
backups when fast-path scan hits EBUSY" relies on platform-specific EBUSY
handling; wrap the part that calls getActionableNamedBackupRestores with an
explicit process.platform override (save original, use Object.defineProperty to
set process.platform to 'win32' for the Windows behavior the test expects, then
restore original afterward) so the expectations remain deterministic; apply the
same pattern to the other related tests mentioned (the ones calling
getActionableNamedBackupRestores and asserting EBUSY behavior) to ensure
consistent platform-specific behavior.
| describe("resolveStartupRecoveryAction", () => { | ||
| it("falls through to OAuth when backups exist but none are actionable", async () => { | ||
| const { resolveStartupRecoveryAction } = await import( | ||
| "../lib/codex-manager.js" | ||
| ); | ||
|
|
||
| expect( | ||
| resolveStartupRecoveryAction( | ||
| { assessments: [], totalBackups: 2 }, | ||
| false, | ||
| ), | ||
| ).toBe("continue-with-oauth"); | ||
| expect( | ||
| resolveStartupRecoveryAction( | ||
| { assessments: [], totalBackups: 2 }, | ||
| false, | ||
| ), | ||
| ).not.toBe("open-empty-storage-menu"); | ||
| }); | ||
|
|
||
| it("falls through to OAuth when the startup recovery scan itself failed", async () => { | ||
| const { resolveStartupRecoveryAction } = await import( | ||
| "../lib/codex-manager.js" | ||
| ); | ||
|
|
||
| expect( | ||
| resolveStartupRecoveryAction( | ||
| { assessments: [], totalBackups: 0 }, | ||
| true, | ||
| ), | ||
| ).toBe("continue-with-oauth"); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
add missing startup recovery routing regressions
test/recovery.test.ts:783-815 only covers oauth fallthrough. it does not cover the positive actionable-backup route or same-session suppression states, which are the highest-risk branches for startup behavior regressions.
please add cases for:
- actionable backups + no suppression => recovery prompt path
- actionable backups + suppression => continue oauth
- zero backups + no scan error => continue oauth (already covered)
As per coding guidelines, test/**: demand regression cases that reproduce critical flow regressions.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@test/recovery.test.ts` around lines 783 - 815, Add regression tests for
resolveStartupRecoveryAction to cover the missing branches: call
resolveStartupRecoveryAction with an input object where assessments contains at
least one actionable assessment (e.g., assessments: [{ actionable: true }]) and
totalBackups > 0 and scanFailed = false and assert it returns the recovery
prompt route (the code path currently implied by
"open-empty-storage-menu"/recovery prompt); then add a test where assessments
contains an actionable item but scanning is suppressed (pass the
suppression/scanFailed flag that triggers same-session suppression) and assert
it returns "continue-with-oauth". Keep the existing tests for zero backups/no
scan error as-is. Reference resolveStartupRecoveryAction and the input shape
(assessments, totalBackups, scanFailed) when adding the two new cases.
| it("rethrows EBUSY backup directory errors after one attempt on non-Windows platforms", async () => { | ||
| const platformSpy = vi | ||
| .spyOn(process, "platform", "get") | ||
| .mockReturnValue("win32"); | ||
| .mockReturnValue("linux"); | ||
| const readdirSpy = vi.spyOn(fs, "readdir"); | ||
| const error = new Error("backup directory busy") as NodeJS.ErrnoException; | ||
| error.code = "EBUSY"; | ||
| readdirSpy.mockRejectedValue(error); | ||
|
|
||
| try { | ||
| await expect(listNamedBackups()).rejects.toMatchObject({ code: "EBUSY" }); | ||
| expect(readdirSpy).toHaveBeenCalledTimes(1); | ||
| } finally { |
There was a problem hiding this comment.
fix platform ambiguity in ebusy retry coverage
test/storage.test.ts:1605-1617 explicitly asserts no EBUSY retries on non-windows, but test/storage.test.ts:1638-1678 expects EBUSY retry success without pinning the platform. this is contradictory and can mask regressions.
proposed test fix
it("retries transient backup directory errors while listing backups", async () => {
+ const platformSpy = vi
+ .spyOn(process, "platform", "get")
+ .mockReturnValue("win32");
await saveAccounts({
version: 3,
activeIndex: 0,
@@
try {
const backups = await listNamedBackups();
@@
} finally {
readdirSpy.mockRestore();
+ platformSpy.mockRestore();
}
});As per coding guidelines, test/**: tests must stay deterministic and use vitest, and demand regression cases that reproduce windows filesystem behavior.
Also applies to: 1638-1678
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@test/storage.test.ts` around lines 1605 - 1617, The two tests are ambiguous
about platform: ensure the non-Windows test for EBUSY retry uses a mocked
process.platform returning "linux" and the Windows-retry test uses
process.platform returning "win32" so behavior is deterministic; in the
non-Windows case (test around listNamedBackups) keep vi.spyOn(process,
"platform", "get").mockReturnValue("linux") and assert readdir was called once,
and in the separate Windows-retry test (lines ~1638-1678) mock process.platform
to "win32" and set readdir to initially reject with an EBUSY error then succeed
on retry to assert retry behavior; also restore/clear spies/mocks after each
test to avoid cross-test leakage.
There was a problem hiding this comment.
Actionable comments posted: 4
♻️ Duplicate comments (2)
test/storage.test.ts (1)
1812-1852:⚠️ Potential issue | 🟠 Majorpin this restore-retry test to win32 for deterministic behavior.
test/storage.test.ts: line 1812asserts transient directory retry on restore but does not mockprocess.platform. this can be host-dependent and flaky if retry logic is windows-specific.proposed fix
it("retries transient backup directory errors while restoring backups", async () => { + const platformSpy = vi + .spyOn(process, "platform", "get") + .mockReturnValue("win32"); await saveAccounts({ version: 3, activeIndex: 0, accounts: [ @@ try { const result = await restoreNamedBackup("retry-restore-dir"); expect(result.total).toBe(1); expect(busyFailures).toBe(1); } finally { readdirSpy.mockRestore(); + platformSpy.mockRestore(); } });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 1812 - 1852, The test for retrying transient directory errors is flaky because it depends on the OS-specific retry behavior; update the test around restoreNamedBackup to pin process.platform to "win32" during the test (use Vitest's vi.stubProperty or equivalent to mock process.platform before calling restoreNamedBackup) so the retry logic is exercised deterministically, then restore the original platform stub in the finally block; ensure you reference restoreNamedBackup and the readdir spy (readdirSpy) so you mock platform only for this test and clean up both the platform stub and readdirSpy in the finally section.lib/codex-manager.ts (1)
4391-4418:⚠️ Potential issue | 🟠 Majorretry transient assessment failures before skipping a backup.
lib/codex-manager.ts: lines 4397-4416skip assessment after a single rejection. transient filesystem failures can hide recoverable backups and suppress restore opportunities in this run.proposed hardening
+async function assessNamedBackupRestoreWithRetry( + backupName: string, + currentStorage: Awaited<ReturnType<typeof loadAccounts>>, +): Promise<BackupRestoreAssessment> { + let attempt = 0; + while (true) { + try { + return await assessNamedBackupRestore(backupName, { currentStorage }); + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + const retryableCode = + code === "EBUSY" || code === "EPERM" || code === "EAGAIN" || code === "EACCES"; + const retryable429 = + typeof (error as { status?: number } | undefined)?.status === "number" && + (error as { status?: number }).status === 429; + if ((!retryableCode && !retryable429) || attempt >= 2) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt)); + attempt += 1; + } + } +} + async function loadBackupRestoreManagerAssessments(): Promise< BackupRestoreAssessment[] > { @@ const chunk = backups.slice(index, index + NAMED_BACKUP_ASSESS_CONCURRENCY); const settledAssessments = await Promise.allSettled( chunk.map((backup) => - assessNamedBackupRestore(backup.name, { currentStorage }), + assessNamedBackupRestoreWithRetry(backup.name, currentStorage), ), );As per coding guidelines,
lib/**: focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lib/codex-manager.ts` around lines 4391 - 4418, The loop that processes backups currently skips a failed assessNamedBackupRestore result after a single rejection; change this to retry transient failures (e.g., filesystem EBUSY, HTTP 429/5xx) a few times with backoff before logging and skipping. Implement retry logic around assessNamedBackupRestore (used in the chunk.map call) with a maxRetries (e.g., 3), exponential or jittered delay, and a transient-error predicate that checks error.code (like "EBUSY") or response/status codes for 429/5xx; keep NAMED_BACKUP_ASSESS_CONCURRENCY unchanged and preserve the Promise.allSettled pattern but call the retried wrapper instead so settledAssessments contains final results; when ultimately logging the skipped backup (using backup.name from chunk[resultIndex]) include the final error message but avoid leaking secrets. Ensure tests (vitest) are updated to cover retry behavior and EBUSY/429 simulated failures.
🤖 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 143-149: The exported type ImportAccountsResult currently marks
changed as optional but importAccounts (and its callers) always produce that
field; make changed required to keep the public contract consistent: update the
type declaration ImportAccountsResult (in lib/storage.ts) to declare changed:
boolean (remove the ?), and ensure any implementations/returns in importAccounts
still supply a boolean for changed; this avoids downstream surprises when
callers later expect changed to exist (see importAccounts function and the
destructuring sites in index.ts for where it matters).
- Around line 2045-2057: The fallback call to importAccounts in
restoreNamedBackup when assessment.backup.loadError is set is likely to repeat
the same failure; change the behavior in restoreNamedBackup to not
unconditionally call importAccounts on loadError—either rethrow or wrap and
throw the original assessment.backup.loadError (or a clear Error including
assessment.error and assessment.backup.loadError details) instead of calling
importAccounts(backupPath); locate restoreNamedBackup and the
assessNamedBackupRestore/assessment.backup.loadError check and replace the
importAccounts fallback with throwing or wrapping the loadError so the
persistent load failure is surfaced cleanly.
- Around line 3003-3015: The nested ternary in computing storage (involving
currentStoragePath, transactionState, and transactionState.snapshot) hides the
validation IIFE that throws on a transaction storage-path mismatch; hoist the
validation: before assigning storage, if transactionState?.active is true verify
(process.platform === "win32" ? transactionState.storagePath?.toLowerCase() ===
currentStoragePath?.toLowerCase() : transactionState.storagePath ===
currentStoragePath) and if it fails throw a clear Error("exportAccounts called
inside an active transaction for a different storage path"); otherwise proceed
to set storage to transactionState.snapshot, and keep the fallback to await
withAccountStorageTransaction((current) => Promise.resolve(current)).
- Around line 287-305: The retry logic in unlinkWithRetry is inconsistent with
retryTransientFilesystemOperation: unlinkWithRetry currently retries
EPERM/EBUSY/EAGAIN on all platforms while isRetryableFilesystemErrorCode (used
by retryTransientFilesystemOperation) restricts EPERM/EBUSY to win32; change
unlinkWithRetry to use the same platform-aware helper (or extract the platform
check into a shared function) so both use isRetryableFilesystemErrorCode (or its
new shared helper) to decide retries for EPERM/EBUSY/EAGAIN, keeping the same
backoff and attempt limits in unlinkWithRetry and preserving ENOENT early-return
behavior.
---
Duplicate comments:
In `@lib/codex-manager.ts`:
- Around line 4391-4418: The loop that processes backups currently skips a
failed assessNamedBackupRestore result after a single rejection; change this to
retry transient failures (e.g., filesystem EBUSY, HTTP 429/5xx) a few times with
backoff before logging and skipping. Implement retry logic around
assessNamedBackupRestore (used in the chunk.map call) with a maxRetries (e.g.,
3), exponential or jittered delay, and a transient-error predicate that checks
error.code (like "EBUSY") or response/status codes for 429/5xx; keep
NAMED_BACKUP_ASSESS_CONCURRENCY unchanged and preserve the Promise.allSettled
pattern but call the retried wrapper instead so settledAssessments contains
final results; when ultimately logging the skipped backup (using backup.name
from chunk[resultIndex]) include the final error message but avoid leaking
secrets. Ensure tests (vitest) are updated to cover retry behavior and EBUSY/429
simulated failures.
In `@test/storage.test.ts`:
- Around line 1812-1852: The test for retrying transient directory errors is
flaky because it depends on the OS-specific retry behavior; update the test
around restoreNamedBackup to pin process.platform to "win32" during the test
(use Vitest's vi.stubProperty or equivalent to mock process.platform before
calling restoreNamedBackup) so the retry logic is exercised deterministically,
then restore the original platform stub in the finally block; ensure you
reference restoreNamedBackup and the readdir spy (readdirSpy) so you mock
platform only for this test and clean up both the platform stub and readdirSpy
in the finally section.
🪄 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: a761cc4e-b662-48c2-a769-9a2bd6cdb527
📒 Files selected for processing (4)
lib/codex-manager.tslib/storage.tstest/codex-manager-cli.test.tstest/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.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.tslib/codex-manager.ts
🔇 Additional comments (5)
lib/storage.ts (5)
3091-3100:deduplicateAccounts(merged)still called twice.at
lib/storage.ts:3092andlib/storage.ts:3100,deduplicateAccounts(merged)is invoked in sequence - once inside the limit guard, once after. past review suggested computing once and reusing.suggested fix (same as prior review)
+ const deduplicatedAccounts = deduplicateAccounts(merged); if (merged.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { - const deduped = deduplicateAccounts(merged); - if (deduped.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { + if (deduplicatedAccounts.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { throw new Error( - `Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts (would have ${deduped.length})`, + `Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts (would have ${deduplicatedAccounts.length})`, ); } } - - const deduplicatedAccounts = deduplicateAccounts(merged);
1825-1852: windows-platform test coverage forretryTransientFilesystemOperationstill missing.per prior review,
test/storage.test.ts:1590-1618covers linux EPERM/EBUSY rethrow (no retry), but there's no explicit test that mocksprocess.platform = "win32"and asserts EPERM/EBUSY are actually retried byretryTransientFilesystemOperation. the constraint shift atlib/storage.ts:1831narrowing retries to win32-only is meaningful and warrants explicit coverage.#!/bin/bash # check if any test mocks win32 platform and exercises retryTransientFilesystemOperation with EPERM/EBUSY rg -n -C5 --type=ts 'retryTransientFilesystemOperation.*EPERM|retryTransientFilesystemOperation.*EBUSY|win32.*retryTransient' test/
1294-1298: LGTM on explicit type annotation.the explicit generic type
const newest: T = selectNewestAccount(...)atlib/storage.ts:1294clarifies intent and matches the surrounding pattern.
2664-2708: signature changed fromPromise<void>toPromise<boolean>- callers properly handle the return value.
lib/storage.ts:2664return type change is appropriately handled:lib/destructive-actions.ts:174and186capture the boolean and return it as part ofDestructiveActionResult. the actual callers inlib/codex-manager.ts:3999and4029-4031inspect the result and log distinct messages on partial failure, so users get feedback when artifact cleanup doesn't fully succeed. tests intest/destructive-actions.test.tsverify the boolean values are correct.> Likely an incorrect or invalid review comment.
2932-2983:clearFlaggedAccountsnow returns success status - callers already handle this.signature changed from
Promise<void>toPromise<boolean>atlib/storage.ts:2932. return value is captured atlib/destructive-actions.ts:188and properly propagated throughDestructiveActionResult. tests verify both success and failure cases attest/destructive-actions.test.ts:61-72andtest/storage-flagged.test.ts.
| export type ImportAccountsResult = { | ||
| imported: number; | ||
| total: number; | ||
| skipped: number; | ||
| // Keep the legacy field in the public type so existing callers do not break. | ||
| changed?: boolean; | ||
| }; |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
ImportAccountsResult.changed is optional but callers may expect it.
the type at lib/storage.ts:148 marks changed as optional (changed?: boolean), but importAccounts at lib/storage.ts:3147 always returns it. downstream callers in index.ts:4119-4150 currently destructure only imported, skipped, total - so this is safe today. if the intent is to keep changed in the public contract, consider making it required to avoid silent undefined checks later.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@lib/storage.ts` around lines 143 - 149, The exported type
ImportAccountsResult currently marks changed as optional but importAccounts (and
its callers) always produce that field; make changed required to keep the public
contract consistent: update the type declaration ImportAccountsResult (in
lib/storage.ts) to declare changed: boolean (remove the ?), and ensure any
implementations/returns in importAccounts still supply a boolean for changed;
this avoids downstream surprises when callers later expect changed to exist (see
importAccounts function and the destructuring sites in index.ts for where it
matters).
| async function unlinkWithRetry(path: string): Promise<void> { | ||
| for (let attempt = 0; attempt < 5; attempt += 1) { | ||
| try { | ||
| await fs.unlink(path); | ||
| return; | ||
| } catch (error) { | ||
| const unlinkError = error as NodeJS.ErrnoException; | ||
| const code = unlinkError.code; | ||
| if (code === "ENOENT") { | ||
| return; | ||
| } | ||
| if ((code === "EPERM" || code === "EBUSY" || code === "EAGAIN") && attempt < 4) { | ||
| await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt)); | ||
| continue; | ||
| } | ||
| throw unlinkError; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
inconsistent retry policy between unlinkWithRetry and retryTransientFilesystemOperation.
unlinkWithRetry at lib/storage.ts:298 retries EPERM/EBUSY/EAGAIN unconditionally across all platforms, but isRetryableFilesystemErrorCode at lib/storage.ts:1831 restricts EPERM/EBUSY retries to win32 only. this creates divergent retry behavior depending on which helper is used.
recommend aligning the retry policies - either extract the platform check into a shared helper or document why unlink needs different behavior.
suggested alignment
async function unlinkWithRetry(path: string): Promise<void> {
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
await fs.unlink(path);
return;
} catch (error) {
const unlinkError = error as NodeJS.ErrnoException;
const code = unlinkError.code;
if (code === "ENOENT") {
return;
}
- if ((code === "EPERM" || code === "EBUSY" || code === "EAGAIN") && attempt < 4) {
+ if (isRetryableFilesystemErrorCode(code) && attempt < 4) {
await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt));
continue;
}
throw unlinkError;
}
}
}📝 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.
| async function unlinkWithRetry(path: string): Promise<void> { | |
| for (let attempt = 0; attempt < 5; attempt += 1) { | |
| try { | |
| await fs.unlink(path); | |
| return; | |
| } catch (error) { | |
| const unlinkError = error as NodeJS.ErrnoException; | |
| const code = unlinkError.code; | |
| if (code === "ENOENT") { | |
| return; | |
| } | |
| if ((code === "EPERM" || code === "EBUSY" || code === "EAGAIN") && attempt < 4) { | |
| await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt)); | |
| continue; | |
| } | |
| throw unlinkError; | |
| } | |
| } | |
| } | |
| async function unlinkWithRetry(path: string): Promise<void> { | |
| for (let attempt = 0; attempt < 5; attempt += 1) { | |
| try { | |
| await fs.unlink(path); | |
| return; | |
| } catch (error) { | |
| const unlinkError = error as NodeJS.ErrnoException; | |
| const code = unlinkError.code; | |
| if (code === "ENOENT") { | |
| return; | |
| } | |
| if (isRetryableFilesystemErrorCode(code) && attempt < 4) { | |
| await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt)); | |
| continue; | |
| } | |
| throw unlinkError; | |
| } | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@lib/storage.ts` around lines 287 - 305, The retry logic in unlinkWithRetry is
inconsistent with retryTransientFilesystemOperation: unlinkWithRetry currently
retries EPERM/EBUSY/EAGAIN on all platforms while isRetryableFilesystemErrorCode
(used by retryTransientFilesystemOperation) restricts EPERM/EBUSY to win32;
change unlinkWithRetry to use the same platform-aware helper (or extract the
platform check into a shared function) so both use
isRetryableFilesystemErrorCode (or its new shared helper) to decide retries for
EPERM/EBUSY/EAGAIN, keeping the same backoff and attempt limits in
unlinkWithRetry and preserving ENOENT early-return behavior.
| export async function restoreNamedBackup( | ||
| name: string, | ||
| ): Promise<ImportAccountsResult> { | ||
| const backupPath = await resolveNamedBackupRestorePath(name); | ||
| const assessment = await assessNamedBackupRestore(name); | ||
| if (!assessment.eligibleForRestore) { | ||
| if (assessment.backup.loadError) { | ||
| return importAccounts(backupPath); | ||
| } | ||
| throw new Error(assessment.error ?? "Backup is not eligible for restore."); | ||
| } | ||
| return importAccounts(backupPath); | ||
| } |
There was a problem hiding this comment.
restore fallback on loadError will likely just fail again.
when !assessment.eligibleForRestore and assessment.backup.loadError is truthy, this code still calls importAccounts(backupPath) at lib/storage.ts:2052. since the assessment already retried loading via loadBackupCandidate, a persistent error will just surface again during import.
if the intent is to surface a cleaner error from importAccounts, consider adding a comment clarifying that. otherwise this looks like dead-code path that never succeeds.
if (!assessment.eligibleForRestore) {
if (assessment.backup.loadError) {
return importAccounts(backupPath); // will likely throw the same error
}
throw new Error(assessment.error ?? "Backup is not eligible for restore.");
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@lib/storage.ts` around lines 2045 - 2057, The fallback call to importAccounts
in restoreNamedBackup when assessment.backup.loadError is set is likely to
repeat the same failure; change the behavior in restoreNamedBackup to not
unconditionally call importAccounts on loadError—either rethrow or wrap and
throw the original assessment.backup.loadError (or a clear Error including
assessment.error and assessment.backup.loadError details) instead of calling
importAccounts(backupPath); locate restoreNamedBackup and the
assessNamedBackupRestore/assessment.backup.loadError check and replace the
importAccounts fallback with throwing or wrapping the loadError so the
persistent load failure is surfaced cleanly.
| const currentStoragePath = getStoragePath(); | ||
| const storage = transactionState?.active | ||
| ? transactionState.snapshot | ||
| : await withAccountStorageTransaction((current) => | ||
| Promise.resolve(current), | ||
| ); | ||
| ? (process.platform === "win32" | ||
| ? transactionState.storagePath?.toLowerCase() === | ||
| currentStoragePath?.toLowerCase() | ||
| : transactionState.storagePath === currentStoragePath) | ||
| ? transactionState.snapshot | ||
| : (() => { | ||
| throw new Error( | ||
| "exportAccounts called inside an active transaction for a different storage path", | ||
| ); | ||
| })() | ||
| : await withAccountStorageTransaction((current) => Promise.resolve(current)); |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
transaction storage-path mismatch throws inside nested ternary - hard to debug.
the IIFE at lib/storage.ts:3010-3014 throws inside a deeply nested ternary, making stack traces less helpful. consider hoisting this validation before the ternary for clearer error attribution.
suggested refactor
const transactionState = transactionSnapshotContext.getStore();
const currentStoragePath = getStoragePath();
+
+if (transactionState?.active) {
+ const pathsMatch = process.platform === "win32"
+ ? transactionState.storagePath?.toLowerCase() === currentStoragePath?.toLowerCase()
+ : transactionState.storagePath === currentStoragePath;
+ if (!pathsMatch) {
+ throw new Error(
+ "exportAccounts called inside an active transaction for a different storage path",
+ );
+ }
+}
+
const storage = transactionState?.active
- ? (process.platform === "win32"
- ? transactionState.storagePath?.toLowerCase() ===
- currentStoragePath?.toLowerCase()
- : transactionState.storagePath === currentStoragePath)
- ? transactionState.snapshot
- : (() => {
- throw new Error(
- "exportAccounts called inside an active transaction for a different storage path",
- );
- })()
+ ? transactionState.snapshot
: await withAccountStorageTransaction((current) => Promise.resolve(current));🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@lib/storage.ts` around lines 3003 - 3015, The nested ternary in computing
storage (involving currentStoragePath, transactionState, and
transactionState.snapshot) hides the validation IIFE that throws on a
transaction storage-path mismatch; hoist the validation: before assigning
storage, if transactionState?.active is true verify (process.platform ===
"win32" ? transactionState.storagePath?.toLowerCase() ===
currentStoragePath?.toLowerCase() : transactionState.storagePath ===
currentStoragePath) and if it fails throw a clear Error("exportAccounts called
inside an active transaction for a different storage path"); otherwise proceed
to set storage to transactionState.snapshot, and keep the fallback to await
withAccountStorageTransaction((current) => Promise.resolve(current)).
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (1)
lib/codex-manager.ts (1)
4105-4144:⚠️ Potential issue | 🟠 Majorset
promptWasShownbefore awaitingconfirm().
lib/codex-manager.ts:4116-4121only flips the guard afterconfirm()resolves. ifconfirm()renders the startup recovery prompt and then throws while reading input, the catch atlib/codex-manager.ts:4138-4139resetsrecoveryPromptAttempted, and the nextloginFlowpass immediately prompts again. please add the thrown-confirm vitest regression for this branch.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.proposed fix
- const restoreNow = await confirm( + promptWasShown = true; + const restoreNow = await confirm( `Found ${recoveryState.assessments.length} recoverable backup${ recoveryState.assessments.length === 1 ? "" : "s" } out of ${recoveryState.totalBackups} total (${backupLabel}) in ${backupDir}. Restore now?`, ); - promptWasShown = true;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lib/codex-manager.ts` around lines 4105 - 4144, The guard flag promptWasShown is set only after awaiting confirm(), so if confirm() throws while the prompt was displayed the catch resets recoveryPromptAttempted and immediately retriggers the prompt; move the promptWasShown = true to before awaiting confirm() (reference promptWasShown and the call to confirm() in the try block) so the catch knows the prompt had been shown, and add a vitest regression that simulates confirm() throwing after render (the "thrown-confirm" test) to assert recoveryPromptAttempted is not reset when promptWasShown was true and ensure logs do not leak tokens/emails.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@lib/codex-manager.ts`:
- Around line 4370-4420: loadBackupRestoreManagerAssessments currently treats
directory-read failures and per-backup rejected assessments as "no backups" by
returning an empty list or skipping failed entries; change it so failures are
preserved as failed BackupRestoreAssessment entries instead of being swallowed:
when listNamedBackups throws, propagate or return a special failed assessment
result rather than [] (so runBackupRestoreManager and codex auth restore-backup
see an error), and inside the Promise.allSettled loop, for any result.status ===
"rejected" push a BackupRestoreAssessment object for that backup with a
failed/errored state and the collapseWhitespace(reason) message (use
assessNamedBackupRestore signature and NAMED_BACKUP_ASSESS_CONCURRENCY to locate
logic). Add vitest regression tests for unreadable-dir and rejected-assessment
cases that assert failures are returned (not an empty array) and that exit paths
in runBackupRestoreManager/codex auth restore-backup reflect failure.
---
Duplicate comments:
In `@lib/codex-manager.ts`:
- Around line 4105-4144: The guard flag promptWasShown is set only after
awaiting confirm(), so if confirm() throws while the prompt was displayed the
catch resets recoveryPromptAttempted and immediately retriggers the prompt; move
the promptWasShown = true to before awaiting confirm() (reference promptWasShown
and the call to confirm() in the try block) so the catch knows the prompt had
been shown, and add a vitest regression that simulates confirm() throwing after
render (the "thrown-confirm" test) to assert recoveryPromptAttempted is not
reset when promptWasShown was true and ensure logs do not leak tokens/emails.
🪄 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: 4c511502-f795-4175-aadb-b45a2c2a530d
📒 Files selected for processing (2)
lib/codex-manager.tstest/codex-manager-cli.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 (1)
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
| async function loadBackupRestoreManagerAssessments(): Promise< | ||
| BackupRestoreAssessment[] | ||
| > { | ||
| let backups: Awaited<ReturnType<typeof listNamedBackups>>; | ||
| try { | ||
| backups = await listNamedBackups(); | ||
| } catch (error) { | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| console.error( | ||
| `Could not read backup directory: ${ | ||
| collapseWhitespace(message) || "unknown error" | ||
| }`, | ||
| ); | ||
| return []; | ||
| } | ||
| if (backups.length === 0) { | ||
| return []; | ||
| } | ||
|
|
||
| const currentStorage = await loadAccounts(); | ||
| const assessments: BackupRestoreAssessment[] = []; | ||
| for ( | ||
| let index = 0; | ||
| index < backups.length; | ||
| index += NAMED_BACKUP_ASSESS_CONCURRENCY | ||
| ) { | ||
| const chunk = backups.slice(index, index + NAMED_BACKUP_ASSESS_CONCURRENCY); | ||
| const settledAssessments = await Promise.allSettled( | ||
| chunk.map((backup) => | ||
| assessNamedBackupRestore(backup.name, { currentStorage }), | ||
| ), | ||
| ); | ||
| for (const [resultIndex, result] of settledAssessments.entries()) { | ||
| if (result.status === "fulfilled") { | ||
| assessments.push(result.value); | ||
| continue; | ||
| } | ||
| const backupName = chunk[resultIndex]?.name ?? "unknown"; | ||
| const reason = | ||
| result.reason instanceof Error | ||
| ? result.reason.message | ||
| : String(result.reason); | ||
| console.warn( | ||
| `Skipped backup assessment for "${backupName}": ${ | ||
| collapseWhitespace(reason) || "unknown error" | ||
| }`, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| return assessments; |
There was a problem hiding this comment.
don't collapse backup assessment failures into the "no backups found" path.
lib/codex-manager.ts:4374-4416 turns both directory-read failures and rejected per-backup assessments into an empty list, so runBackupRestoreManager() falls through to no named backups found at lib/codex-manager.ts:4437-4439. that also makes codex auth restore-backup exit 0 at lib/codex-manager.ts:4571-4575 even when the backup dir is unreadable or the chunked assessor hit a transient windows fs error. lib/storage.ts:1858-1956 already preserves failed entries in allAssessments; reusing that path here would keep corrupt/unreadable backups visible and let real load failures return "failed" instead of looking like success. please add a vitest regression for unreadable-dir and rejected-assessment cases.
proposed fix
async function loadBackupRestoreManagerAssessments(): Promise<
BackupRestoreAssessment[]
> {
- let backups: Awaited<ReturnType<typeof listNamedBackups>>;
- try {
- backups = await listNamedBackups();
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error);
- console.error(
- `Could not read backup directory: ${
- collapseWhitespace(message) || "unknown error"
- }`,
- );
- return [];
- }
+ const backups = await listNamedBackups();
if (backups.length === 0) {
return [];
}
- const currentStorage = await loadAccounts();
- const assessments: BackupRestoreAssessment[] = [];
- for (
- let index = 0;
- index < backups.length;
- index += NAMED_BACKUP_ASSESS_CONCURRENCY
- ) {
- const chunk = backups.slice(index, index + NAMED_BACKUP_ASSESS_CONCURRENCY);
- const settledAssessments = await Promise.allSettled(
- chunk.map((backup) =>
- assessNamedBackupRestore(backup.name, { currentStorage }),
- ),
- );
- for (const [resultIndex, result] of settledAssessments.entries()) {
- if (result.status === "fulfilled") {
- assessments.push(result.value);
- continue;
- }
- const backupName = chunk[resultIndex]?.name ?? "unknown";
- const reason =
- result.reason instanceof Error
- ? result.reason.message
- : String(result.reason);
- console.warn(
- `Skipped backup assessment for "${backupName}": ${
- collapseWhitespace(reason) || "unknown error"
- }`,
- );
- }
- }
-
- return assessments;
+ const { allAssessments } = await getActionableNamedBackupRestores({
+ backups,
+ currentStorage: await loadAccounts(),
+ });
+ return allAssessments;
}
...
- const assessments =
- assessmentsOverride ?? (await loadBackupRestoreManagerAssessments());
+ let assessments: BackupRestoreAssessment[];
+ try {
+ assessments =
+ assessmentsOverride ?? (await loadBackupRestoreManagerAssessments());
+ } catch (error) {
+ const errorLabel = getRedactedFilesystemErrorLabel(error);
+ console.error(`Could not load backup assessments (${errorLabel}).`);
+ return "failed";
+ }Also applies to: 4435-4439, 4571-4575
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@lib/codex-manager.ts` around lines 4370 - 4420,
loadBackupRestoreManagerAssessments currently treats directory-read failures and
per-backup rejected assessments as "no backups" by returning an empty list or
skipping failed entries; change it so failures are preserved as failed
BackupRestoreAssessment entries instead of being swallowed: when
listNamedBackups throws, propagate or return a special failed assessment result
rather than [] (so runBackupRestoreManager and codex auth restore-backup see an
error), and inside the Promise.allSettled loop, for any result.status ===
"rejected" push a BackupRestoreAssessment object for that backup with a
failed/errored state and the collapseWhitespace(reason) message (use
assessNamedBackupRestore signature and NAMED_BACKUP_ASSESS_CONCURRENCY to locate
logic). Add vitest regression tests for unreadable-dir and rejected-assessment
cases that assert failures are returned (not an empty array) and that exit paths
in runBackupRestoreManager/codex auth restore-backup reflect failure.
|
Closing this preserved |
Summary
#77and#92fresh/02-backup-restore-managerWhat Changed
freshorresetactions.Validation
npm run lintnpm run typechecknpm testnpm test -- test/documentation.test.tsnpm run buildDocs and Governance Checklist
docs/getting-started.mdupdated (if onboarding flow changed)docs/features.mdupdated (if capability surface changed)docs/reference/*pages updated (if commands/settings/paths changed)docs/upgrade.mdupdated (if migration behavior changed)SECURITY.mdandCONTRIBUTING.mdreviewed for alignmentRisk and Rollback
12074e2and390f801Additional Notes
fresh/04-sync-center; later branches carry an equivalent cleanup commit but are not direct descendants of390f801.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 wires in the startup-recovery prompt that was preserved from archived PRs #77 and #92: when
codex auth loginopens with zero saved accounts and recoverable named backups exist, the user is offered a restore before oauth. thesuppressRecoveryPromptandrecoveryPromptAttemptedflags gate the prompt correctly — suppressed after same-sessionfresh/reset, and not re-shown on a deliberate dismiss. several previously flagged issues are resolved:formatRelativeDateShortnow correctly guardstimestamp <= 0and non-finite values,restore-backupresets storage path before entering the manager,resolveStartupRecoveryActionreturns"continue-with-oauth"when backups exist but none are actionable (and the test expectation matches),NAMED_BACKUP_ASSESS_CONCURRENCYis properly kept at 4, andEBUSYis platform-gated to win32.key remaining concerns:
"failed"restore result path resetsrecoveryPromptAttempted = falsewith no retry cap — a persistent file lock on windows will trap the user in an infinite prompt/fail loop with no escape to oauth short of ctrl-c.restoreNamedBackupperforms a third eligibility assessment without passingcurrentStorage, introducing a narrow race window vs the confirmation the user just saw.Confidence Score: 2/5
runAuthLoginis capped — a persistent windows file lock will trap users indefinitely on startup."dismissed"loop from the previous round is fixed (setssuppressRecoveryPrompt = true), but the"failed"path resetsrecoveryPromptAttempted = falsewithout any cap, creating an equivalent infinite loop on persistent file errors. the windows token/filesystem risk is real and reproducible with a locked backup file. the rest of the changes (concurrency cap at 4, platform-gatedEBUSY,formatRelativeDateShortguards, storage path reset) look correct, and the test regression forresolveStartupRecoveryActionis resolved. but the loop bug and the missing vitest coverage lower confidence.lib/codex-manager.tslines 4127–4135 (failed-restore retry path),lib/storage.tslines 2045–2056 (restoreNamedBackupthird assessment withoutcurrentStorage)Important Files Changed
resolveStartupRecoveryAction;"failed"restore path resetsrecoveryPromptAttemptedwith no retry cap, creating an unbounded loop on windows with persistent file locks; staleallAssessmentspassed to retry menu.EBUSYplatform-gated to win32;assessNamedBackupRestoreproperly guardschanged/eligibleForRestore;restoreNamedBackupperforms a redundant third assessment without passingcurrentStorage, introducing a narrow race window.DESTRUCTIVE_ACTION_COPYhas proper separate keys fordeleteSavedAccountsandresetLocalState;suppressRecoveryPrompt = trueset correctly after bothfreshandresetactions."continue-with-oauth"matching the implementation; retry budget path (5 attempts) validated for locked backup; no coverage for unbounded failed-restore loop.getActionableNamedBackupRestoresintegration covered but no test for the import retry-after-failure scenario.fresh/reset; no coverage for unbounded"failed"loop scenario.Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A[runAuthLogin start] --> B[loadAccounts] B --> C{accounts > 0 OR\ncanOpenEmptyStorageMenu?} C -- yes --> D[show auth menu loop] D -- fresh/reset --> E[suppressRecoveryPrompt = true\ncontinue loginFlow] D -- add --> F[exit inner loop] F --> G[canPromptForRecovery check] C -- no --> G G -- suppressed OR\nalready attempted OR\nnot interactive --> L[runOAuthFlow] G -- eligible --> H[getActionableNamedBackupRestores] H -- no backups --> I[allowEmptyStorageMenu = true\ncontinue loginFlow] H -- backups exist,\nnone actionable --> L H -- actionable backups --> J[confirm prompt] J -- no --> L J -- yes --> K[runBackupRestoreManager] K -- restored --> L K -- dismissed --> M[suppressRecoveryPrompt = true\ncontinue loginFlow] K -- failed --> N[pendingRecoveryState cached\nrecoveryPromptAttempted = false\ncontinue loginFlow] N --> G style N fill:#ff9999,color:#000 N -- no cap! --> GComments Outside Diff (8)
lib/codex-manager.ts, line 4596-4602 (link)autoSyncActiveAccountToCodexdropped — codex CLI auth state not updated after standalone restorethe old
runBackupRestoreManagercalledautoSyncActiveAccountToCodex()after every successful write. the newrestoreNamedBackuppath skips this entirely. for the standalonecodex auth restore-backupcommand the codex CLI active-account file is never rewritten, so the CLI stays pointing at whichever account was active before the restore (or at a stale entry).when used inside
runAuthLoginthis is less severe because the user will eventually complete an OAuth step andsyncSelectionToCodexfires. but for the direct command path the sync is the only opportunity and it's now gone.the removed call:
suggest calling
autoSyncActiveAccountToCodex()(with a non-fatal catch) afterrestoreNamedBackupsucceeds, at minimum for the standalone command path.Prompt To Fix With AI
lib/storage.ts, line 1082-1104 (link)the old code scaled jitter proportionally to the base delay:
the new code always jitters 0–9 ms regardless of attempt:
on windows, antivirus scanners hold locks for bursts that often exceed 100 ms. concentrated retry windows (e.g., all four retries landing within 90 ms) reduce the chance of clearing the lock between attempts. the old proportional jitter was more effective for distributing retry pressure. this regression is compounded by the already-reduced 5-attempt budget (down from 7). no vitest coverage for the jitter distribution.
Prompt To Fix With AI
lib/storage.ts, line 1316-1318 (link)restoreNamedBackupno longer enforceseligibleForRestoreor the account limitthe old
restoreAssessedNamedBackupvalidated eligibility before writing:runBackupRestoreManagerincodex-manager.tsdoes callassessNamedBackupRestoreand checklatestAssessment.eligibleForRestorebefore invokingrestoreNamedBackup, so the current UI path is guarded. however,restoreNamedBackupis an exported function — any caller that reaches it directly (e.g., future automation, programmatic use, or a test that mocks around the UI layer) will bypass both thewouldExceedLimitguard and theeligibleForRestorecheck, landing straight inimportAccounts.the
importAccountsfunction does enforce the limit via a throw, so catastrophic writes are blocked, but an ineligible restore due to an unparseable backup would still proceed and likely corrupt the import silently. suggest reinstating a minimal eligibility gate:Prompt To Fix With AI
lib/storage.ts, line 1579-1594 (link)the old code called
assertNamedBackupRestorePath(withrealpathSync.native) for every resolved path before use:the new code drops this entirely:
scanNamedBackupsandlistNamedBackupsWithoutLoadingdo filterentry.isSymbolicLink(), which handles symlinks at listing time. however, on windows, ntfs directory junctions (mklink /j) are not symlinks —entry.isSymbolicLink()returnsfalsefor them whilerealpathSync.nativeresolves through them. abackups/directory itself configured as a junction to a directory outside the data root would not be caught by the new filter.more concretely:
resolveNamedBackupRestorePathis also reached viaassessNamedBackupRestore→resolveNamedBackupRestorePath, which is called directly fromrestoreNamedBackup. if the backup root is a junction, the canonical path will silently escape the expected directory boundary. no vitest coverage for this scenario.Prompt To Fix With AI
test/recovery.test.ts, line 790-812 (link)missing required
allAssessmentsfield in test fixturesActionableNamedBackupRecoveries(added in this PR atlib/storage.ts) declaresallAssessments: BackupRestoreAssessment[]as a required field, but all three call sites here pass an object literal without it. typescript should flag this asProperty 'allAssessments' is missingatnpm run typecheck. the function only readsassessmentsandtotalBackupsat runtime so tests pass, but the type contract is broken.same fix needed for the
totalBackups: 0fixture on line ~809.lib/codex-manager.ts, line 4116-4138 (link)promptWasShownflag set beforeconfirm()call — silently eats TTY failurespromptWasShown = trueis written one line beforeawait confirm(...). ifconfirm()throws before painting any output (TTY closed, readline init failure, etc.), the catch block seespromptWasShown === true, skips therecoveryPromptAttempted = falsereset, and the user's only chance at startup recovery is silently dropped. the flag's stated purpose is to distinguish "error happened before the user saw anything" from "error happened after", but the current placement defeats that onconfirm()failures.move the flag set to after
confirm()resolves (or after the prompt is guaranteed to have been painted):no vitest coverage for the
confirm()throw-before-display path.lib/codex-manager.ts, line 4127-4135 (link)"failed"restore path retries indefinitelywhen
restoreResult === "failed",recoveryPromptAttemptedis reset tofalsewith no cap. on the nextloginFlowiteration,existingCount === 0and!suppressRecoveryPromptstill hold, socanPromptForRecoveryfires again — another confirm, another restore attempt, another failure, ad infinitum. the only escapes are ctrl-c or a successful restore.this is particularly dangerous on windows where a persistent
EPERM/EBUSYwill exhaust the 5-attempt retry budget and return"failed"on every iteration. the user is stuck in the recovery loop and can never reach oauth.suggest adding a retry limit, or using a dedicated counter, or simply falling through to oauth after a single failure:
missing vitest coverage for the failed-restore loop — no test verifies that repeated
"failed"results eventually suppress the prompt.lib/storage.ts, line 2045-2056 (link)restoreNamedBackupcallsassessNamedBackupRestorewithoutcurrentStorageassessNamedBackupRestore(name)at line 2049 loads accounts fresh from disk with no passedcurrentStorage. butrunBackupRestoreManageralready calledassessNamedBackupRestorewith a current-storage snapshot at line 4493 just before confirming.restoreNamedBackupthen re-assesses a third time, using whatever is on disk at that instant — if another process wrote an account between the confirmation and the actual import, the eligibility guard here will operate on different state than what the user confirmed.this is a narrow race but worth noting on windows where concurrent writes are more likely. passing
currentStoragethrough or removing the redundant re-assessment insiderestoreNamedBackupwould close the window.Prompt To Fix All With AI
Last reviewed commit: "fix(auth): retry sta..."